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

4273 lines
160 KiB
Python
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

from __future__ import annotations
from typing import Any, Dict, List, Optional
from decimal import Decimal
from datetime import datetime, date
import logging
from collections import defaultdict
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import Integer, and_, cast, func, or_
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.exc import IntegrityError
logger = logging.getLogger(__name__)
from adapters.db.models.warehouse_document import WarehouseDocument
from adapters.db.models.warehouse_document_line import WarehouseDocumentLine
from adapters.db.models.document import Document
from adapters.db.models.document_line import DocumentLine
from adapters.db.models.product import Product
from adapters.db.models.account import Account
from adapters.db.models.fiscal_year import FiscalYear
from app.core.responses import ApiError
from adapters.db.models.warehouse import Warehouse
from adapters.db.models.user import User
from adapters.db.models.person import Person
from adapters.db.models.category import BusinessCategory
from adapters.db.repositories.warehouse_repository import WarehouseRepository
from adapters.api.v1.schema_models.warehouse import WarehouseCreateRequest, WarehouseUpdateRequest
from adapters.api.v1.schemas import QueryInfo, FilterItem
from app.services.query_service import QueryService
from app.services.product_attribute_service import validate_custom_attributes
from app.services.document_numbering_service import generate_document_code
_INVOICE_TYPE_LABELS_FA = {
"invoice_sales": "فاکتور فروش",
"invoice_sales_return": "برگشت از فروش",
"invoice_purchase": "فاکتور خرید",
"invoice_purchase_return": "برگشت از خرید",
"invoice_direct_consumption": "مصرف مستقیم",
"invoice_production": "فاکتور تولید",
"invoice_waste": "فاکتور ضایعات",
}
_SOURCE_TYPE_LABELS_FA = {
"manual": "دستی",
"invoice": "فاکتور",
"api": "API",
"goods_expense_income": "کالای هزینه/درآمد شده",
}
def _warehouse_document_source_is_invoice(db: Session, business_id: int, wh: WarehouseDocument) -> bool:
"""آیا موجودی این حواله از مسیر فاکتور (نه خطوط حواله) شمرده می‌شود؟"""
st = (getattr(wh, "source_type", None) or "").strip().lower()
if st == "invoice":
return True
src_doc_id = getattr(wh, "source_document_id", None)
if src_doc_id is None:
return False
from app.services.invoice_service import SUPPORTED_INVOICE_TYPES
row = (
db.query(Document.document_type)
.filter(Document.id == int(src_doc_id), Document.business_id == int(business_id))
.first()
)
if row is None:
return False
return str(row[0] or "") in SUPPORTED_INVOICE_TYPES
def should_seal_warehouse_cancel_reversal(db: Session, business_id: int, wh: WarehouseDocument) -> bool:
"""
اگر True باشد، لغو حوالهٔ اصلی خودش موجودی فیزیکی را اصلاح می‌کند
(اصل به cancelled می‌رود و از مجموع posted حذف می‌شود)؛ بنابراین حوالهٔ معکوس
فقط بایگانی است و نباید draft/قابل‌پست بماند.
برای حواله‌های فاکتور، جریان فعلی draft→post برای بایگانی حفظ می‌شود.
"""
return not _warehouse_document_source_is_invoice(db, business_id, wh)
def assert_cancel_reversal_postable(db: Session, wh: WarehouseDocument) -> None:
"""جلوگیری از پست حوالهٔ معکوس وقتی موجودی قبلاً با لغو اصل اصلاح شده است."""
ex = wh.extra_info or {}
if not isinstance(ex, dict):
return
cancel_id = ex.get("cancels_warehouse_document_id")
if cancel_id is None:
return
if ex.get("audit_only_reversal") or ex.get("stock_already_corrected_by_cancel"):
raise ApiError(
"CANCEL_REVERSAL_NOT_POSTABLE",
"این حوالهٔ معکوس فقط برای بایگانی است؛ موجودی با لغو حوالهٔ اصلی اصلاح شده و قابل قطعی‌سازی نیست",
http_status=400,
)
try:
cid = int(cancel_id)
except (TypeError, ValueError):
return
original = (
db.query(WarehouseDocument)
.filter(
WarehouseDocument.id == cid,
WarehouseDocument.business_id == int(wh.business_id),
)
.first()
)
if not original:
return
if (original.status or "").strip().lower() != "cancelled":
return
# اصل لغو شده و موجودی از خطوط حواله شمرده می‌شود → پست معکوس دوبرابر می‌کند
if should_seal_warehouse_cancel_reversal(db, int(wh.business_id), original):
raise ApiError(
"CANCEL_REVERSAL_NOT_POSTABLE",
"این حوالهٔ معکوس قابل قطعی‌سازی نیست؛ موجودی با لغو حوالهٔ اصلی اصلاح شده است",
http_status=400,
)
_WH_DOC_TYPE_EXPORT_FA = {
"receipt": "ورود",
"issue": "خروج",
"transfer": "انتقال بین انبار",
"adjustment": "تعدیل موجودی",
"production_in": "ورود تولید",
"production_out": "خروج تولید",
}
_WH_STATUS_EXPORT_FA = {
"draft": "پیش‌نویس",
"posted": "قطعی",
"cancelled": "لغو شده",
}
_WAREHOUSE_DOC_SEARCH_FIELDS_DEFAULT = frozenset({"code", "counterparty"})
def _warehouse_doc_search_fields_set(search_fields: Optional[List[Any]]) -> set[str]:
if search_fields:
sf_set = {str(x).lower() for x in search_fields}
else:
sf_set = set(_WAREHOUSE_DOC_SEARCH_FIELDS_DEFAULT)
if "source_invoice_party_name" in sf_set:
sf_set.add("counterparty")
return sf_set
def _invoice_ids_matching_warehouse_party_search(
db: Session,
business_id: int,
search: str,
) -> List[int]:
"""فاکتورهایی که نام طرف حساب (person_name یا Person) با عبارت جستجو match می‌شود."""
s = f"%{search.strip()}%"
_jb = cast(Document.extra_info, JSONB)
pid_expr = cast(_jb["person_id"].astext, Integer)
person_name_expr = _jb["person_name"].astext
q = db.query(Document.id).filter(Document.business_id == business_id)
q = q.outerjoin(
Person,
and_(Person.id == pid_expr, Person.business_id == business_id),
)
full_name = func.nullif(func.trim(func.concat_ws(" ", Person.first_name, Person.last_name)), "")
party_match = or_(
person_name_expr.ilike(s),
Person.alias_name.ilike(s),
Person.first_name.ilike(s),
Person.last_name.ilike(s),
Person.company_name.ilike(s),
full_name.ilike(s),
)
return [r[0] for r in q.filter(party_match).limit(500).all()]
def _apply_warehouse_documents_text_search(
q,
*,
db: Session,
business_id: int,
search: Optional[str],
search_fields: Optional[List[Any]] = None,
):
"""جستجوی متنی لیست حواله انبار: کد حواله، کد فاکتور مبدأ، نام طرف حساب."""
if not isinstance(search, str) or not search.strip():
return q
s = f"%{search.strip()}%"
sf_set = _warehouse_doc_search_fields_set(search_fields)
or_parts: List[Any] = []
if "code" in sf_set:
or_parts.append(WarehouseDocument.code.ilike(s))
invoice_ids_code = [
r[0]
for r in db.query(Document.id)
.filter(and_(Document.business_id == business_id, Document.code.ilike(s)))
.limit(500)
.all()
]
if invoice_ids_code:
or_parts.append(
and_(
WarehouseDocument.source_type == "invoice",
WarehouseDocument.source_document_id.in_(invoice_ids_code),
)
)
if "counterparty" in sf_set:
invoice_ids_party = _invoice_ids_matching_warehouse_party_search(db, business_id, search)
if invoice_ids_party:
or_parts.append(
and_(
WarehouseDocument.source_type == "invoice",
WarehouseDocument.source_document_id.in_(invoice_ids_party),
)
)
if not or_parts:
or_parts.append(WarehouseDocument.code.ilike(s))
return q.filter(or_(*or_parts))
def warehouse_documents_filtered_query(db: Session, business_id: int, body: Dict[str, Any]):
"""همان فیلترهای جستجوی حواله انبار (بدون مرتب‌سازی و صفحه‌بندی)."""
from app.services.transfer_service import _parse_iso_date as _parse_date
doc_type = body.get("doc_type")
if isinstance(doc_type, list) and doc_type:
doc_type = doc_type[0]
elif not isinstance(doc_type, str):
doc_type = None
status = body.get("status")
if isinstance(status, list) and status:
status = status[0]
elif not isinstance(status, str):
status = None
warehouse_id = body.get("warehouse_id")
warehouse_ids = body.get("warehouse_ids")
if not warehouse_id and isinstance(warehouse_ids, list) and warehouse_ids:
warehouse_id = warehouse_ids[0]
q = db.query(WarehouseDocument).filter(WarehouseDocument.business_id == business_id)
filters_list = body.get("filters") if isinstance(body.get("filters"), list) else []
ms_doc_types: List[str] = []
ms_statuses: List[str] = []
ms_source_types: List[str] = []
for f in filters_list:
if not isinstance(f, dict):
continue
prop = f.get("property")
op = f.get("operator")
val = f.get("value")
if prop == "doc_type" and op == "in" and isinstance(val, list):
ms_doc_types = [str(x) for x in val if x]
elif prop == "status" and op == "in" and isinstance(val, list):
ms_statuses = [str(x) for x in val if x]
elif prop == "source_type" and op == "in" and isinstance(val, list):
ms_source_types = [str(x) for x in val if x]
if ms_doc_types:
q = q.filter(WarehouseDocument.doc_type.in_(ms_doc_types))
elif isinstance(doc_type, str) and doc_type:
q = q.filter(WarehouseDocument.doc_type == doc_type)
elif isinstance(body.get("doc_type"), list):
doc_type_list = body.get("doc_type")
if doc_type_list:
q = q.filter(WarehouseDocument.doc_type.in_(doc_type_list))
if ms_statuses:
q = q.filter(WarehouseDocument.status.in_(ms_statuses))
elif isinstance(status, str) and status:
q = q.filter(WarehouseDocument.status == status)
elif isinstance(body.get("status"), list):
status_list = body.get("status")
if status_list:
q = q.filter(WarehouseDocument.status.in_(status_list))
source_document_id = body.get("source_document_id")
if isinstance(source_document_id, int):
q = q.filter(WarehouseDocument.source_document_id == source_document_id)
source_type = body.get("source_type")
if ms_source_types:
q = q.filter(WarehouseDocument.source_type.in_(ms_source_types))
elif isinstance(source_type, str) and source_type:
q = q.filter(WarehouseDocument.source_type == source_type)
from_date = body.get("from_date")
to_date = body.get("to_date")
try:
if isinstance(from_date, str) and from_date:
q = q.filter(WarehouseDocument.document_date >= _parse_date(from_date))
if isinstance(to_date, str) and to_date:
q = q.filter(WarehouseDocument.document_date <= _parse_date(to_date))
except Exception:
pass
if warehouse_id:
q = q.filter(
or_(
WarehouseDocument.warehouse_id_from == int(warehouse_id),
WarehouseDocument.warehouse_id_to == int(warehouse_id),
)
)
elif isinstance(warehouse_ids, list) and warehouse_ids:
wh_ids = [int(w) for w in warehouse_ids if w]
if wh_ids:
q = q.filter(
or_(
WarehouseDocument.warehouse_id_from.in_(wh_ids),
WarehouseDocument.warehouse_id_to.in_(wh_ids),
)
)
q = _apply_warehouse_documents_text_search(
q,
db=db,
business_id=business_id,
search=body.get("search"),
search_fields=body.get("search_fields"),
)
return q
def apply_warehouse_documents_sort(q, body: Dict[str, Any]):
"""همان مرتب‌سازی جستجوی حواله انبار."""
from app.services.sort_resolution import effective_sort_specs, query_info_for_sort
_wh_sort_allowed = frozenset({"code", "doc_type", "status", "created_at", "document_date"})
def _wh_sort_col(name: str):
if name == "code":
return WarehouseDocument.code
if name == "doc_type":
return WarehouseDocument.doc_type
if name == "status":
return WarehouseDocument.status
if name == "created_at":
return WarehouseDocument.created_at
return WarehouseDocument.document_date
_qi = query_info_for_sort(body, default_sort_desc=True)
_specs = effective_sort_specs(_qi, allowed=_wh_sort_allowed, default_when_empty=("document_date", True))
_order_parts = []
for _n, _d in _specs:
_c = _wh_sort_col(_n)
_order_parts.append(_c.desc() if _d else _c.asc())
_order_parts.append(WarehouseDocument.id.desc())
return q.order_by(*_order_parts)
def export_warehouse_documents_excel(db: Session, business_id: int, body: Dict[str, Any]) -> bytes:
"""خروجی Excel لیست حواله‌های انبار (حداکثر ۱۰٬۰۰۰ ردیف)، با همان فیلترهای جستجو."""
try:
import io
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill
q = warehouse_documents_filtered_query(db, business_id, body)
q = apply_warehouse_documents_sort(q, body)
items = q.limit(10000).all()
wb = Workbook()
ws = wb.active
ws.title = "WarehouseDocs"
ws.sheet_view.rightToLeft = True
headers = [
"کد حواله",
"نوع",
"وضعیت",
"تاریخ سند",
"منشأ",
"انبار مبدأ",
"انبار مقصد",
"جمع مقدار",
"ایجاد کننده",
"کد سند مرتبط",
"توضیحات",
]
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
header_font = Font(bold=True, color="FFFFFF", size=12)
for col_num, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col_num, value=header)
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal="center", vertical="center")
for row_num, wh in enumerate(items, 2):
r = warehouse_document_to_dict(db, wh)
dt = r.get("doc_type") or ""
st = r.get("status") or ""
ws.cell(row=row_num, column=1, value=r.get("code"))
ws.cell(row=row_num, column=2, value=_WH_DOC_TYPE_EXPORT_FA.get(str(dt), str(dt)))
ws.cell(row=row_num, column=3, value=_WH_STATUS_EXPORT_FA.get(str(st), str(st)))
ws.cell(row=row_num, column=4, value=str(r.get("document_date") or ""))
src = r.get("source_type_label_fa") or r.get("source_type") or ""
ws.cell(row=row_num, column=5, value=src)
ws.cell(row=row_num, column=6, value=r.get("warehouse_name_from") or "")
ws.cell(row=row_num, column=7, value=r.get("warehouse_name_to") or "")
ws.cell(row=row_num, column=8, value=r.get("total_quantity"))
ws.cell(row=row_num, column=9, value=r.get("created_by_name") or "")
ws.cell(row=row_num, column=10, value=r.get("source_document_code") or "")
ws.cell(row=row_num, column=11, value=r.get("description") or "")
for col_letter, width in zip(
["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K"],
[14, 18, 12, 14, 12, 18, 18, 12, 22, 16, 28],
):
ws.column_dimensions[col_letter].width = width
output = io.BytesIO()
wb.save(output)
output.seek(0)
return output.read()
except ImportError:
raise ApiError(
"OPENPYXL_NOT_INSTALLED",
"openpyxl library is not installed",
http_status=500,
) from None
except Exception as e:
logger.exception("export_warehouse_documents_excel failed")
raise ApiError("EXPORT_FAILED", str(e), http_status=500) from e
def _person_display_name_invoice_party(p: Person) -> str:
if getattr(p, "company_name", None) and str(p.company_name).strip():
return str(p.company_name).strip()
parts = [getattr(p, "first_name", None) or "", getattr(p, "last_name", None) or ""]
name = " ".join(x for x in parts if x).strip()
if name:
return name
alias = getattr(p, "alias_name", None)
return (str(alias).strip() if alias else "") or ""
def _get_current_fiscal_year(db: Session, business_id: int) -> FiscalYear:
fy = db.query(FiscalYear).filter(and_(FiscalYear.business_id == business_id, FiscalYear.is_last == True)).first()
if not fy:
raise ApiError("NO_FISCAL_YEAR", "No active fiscal year found for this business", http_status=400)
return fy
def _generate_warehouse_document_code(db: Session, business_id: int, document_date: date) -> str:
"""کد حواله انبار بر اساس تنظیمات شماره‌گذاری اسناد (نوع warehouse_document)."""
return generate_document_code(db, business_id, "warehouse_document", document_date)
def _is_duplicate_warehouse_document_code_error(exc: IntegrityError) -> bool:
"""تشخیص برخورد یکتایی روی کد حواله انبار (PostgreSQL و MySQL، قبل/بعد از migration ترکیبی)."""
text = f"{exc} {getattr(exc, 'orig', '')}".lower()
if "uq_warehouse_documents_business_id_code" in text:
return True
if "ix_warehouse_documents_code" in text:
return True
if "duplicate entry" in text and ("warehouse_documents.code" in text or "`code`" in text):
return True
return False
# هم‌تراز با ستون warehouse_document_lines.quantity (Numeric(18, 6) در PostgreSQL)
_MAX_WAREHOUSE_LINE_QUANTITY = Decimal("999999999999.999999")
def _parse_stock_count_adjustment_quantity(raw: Any, product_label: str) -> Optional[Decimal]:
"""Parse و اعتبارسنجی تعداد خط حواله قبل از INSERT تا از numeric overflow در DB جلوگیری شود."""
try:
q = Decimal(str(raw))
except Exception:
raise ApiError(
"INVALID_QUANTITY",
f"مقدار تعداد برای کالا ({product_label}) نامعتبر است.",
http_status=400,
)
if q <= 0:
return None
if not q.is_finite():
raise ApiError(
"INVALID_QUANTITY",
f"مقدار تعداد برای کالا ({product_label}) نامعتبر است.",
http_status=400,
)
q = q.quantize(Decimal("0.000001"))
if q <= 0:
return None
if q > _MAX_WAREHOUSE_LINE_QUANTITY:
raise ApiError(
"QUANTITY_OUT_OF_RANGE",
(
"مقدار تعداد تعدیل از حد مجاز سیستم بیشتر است. "
f"حداکثر مجاز {_MAX_WAREHOUSE_LINE_QUANTITY} است؛ لطفاً مقادیر شمارش یا تفاوت را بررسی کنید."
),
http_status=400,
)
return q
def _generate_auto_warehouse_code(db: Session, business_id: int) -> str:
"""تولید کد خودکار برای انبار: WH-00001, WH-00002, ..."""
from sqlalchemy import func, select
# دریافت آخرین کد انبار
last_warehouse = (
db.query(Warehouse)
.filter(Warehouse.business_id == business_id)
.order_by(Warehouse.id.desc())
.first()
)
if last_warehouse and last_warehouse.code:
# استخراج عدد از آخر کد (فرمت WH-00001)
import re
numbers = re.findall(r'\d+', last_warehouse.code)
if numbers:
try:
last_number = int(numbers[-1])
return f"WH-{last_number + 1:05d}"
except ValueError:
pass
# اگر انبار قبلی نداشت یا فرمت نامعتبر بود
max_id = db.execute(select(func.max(Warehouse.id))).scalar() or 0
return f"WH-{max_id + 1:05d}"
def _optional_line_int(value: Any) -> Optional[int]:
if value is None or value == "":
return None
try:
return int(value)
except Exception:
return None
def _warehouse_location_id_from_payload(ln: Dict[str, Any]) -> Optional[int]:
v = ln.get("warehouse_location_id")
if v is None and isinstance(ln.get("extra_info"), dict):
v = ln["extra_info"].get("warehouse_location_id")
return _optional_line_int(v)
def _transfer_location_pair_from_payload(ln: Dict[str, Any]) -> tuple[Optional[int], Optional[int]]:
o = ln.get("warehouse_location_id_from") or ln.get("warehouse_location_out")
i = ln.get("warehouse_location_id_to") or ln.get("warehouse_location_in")
return _optional_line_int(o), _optional_line_int(i)
def _warehouse_line_matches_invoice_movement(invoice_document_type: str, movement: str) -> bool:
from app.services.invoice_service import (
INVOICE_SALES,
INVOICE_SALES_RETURN,
INVOICE_PURCHASE,
INVOICE_PURCHASE_RETURN,
INVOICE_DIRECT_CONSUMPTION,
INVOICE_PRODUCTION,
INVOICE_WASTE,
)
if invoice_document_type in (
INVOICE_SALES,
INVOICE_PURCHASE_RETURN,
INVOICE_DIRECT_CONSUMPTION,
INVOICE_PRODUCTION,
INVOICE_WASTE,
):
return movement == "out"
if invoice_document_type in (INVOICE_PURCHASE, INVOICE_SALES_RETURN):
return movement == "in"
return False
_WAREHOUSE_QTY_EPSILON = Decimal("0.000001")
def _compute_line_quantities_core(
invoice_document_type: str,
item_rows: List[Any],
warehouse_docs: List[WarehouseDocument],
) -> List[Dict[str, Any]]:
"""هستهٔ محاسبه required/processed/remaining برای ردیف‌های فاکتور."""
processed_by_line: Dict[int, Decimal] = {}
orphan_by_product: Dict[int, Decimal] = {}
for wh_doc in warehouse_docs:
if (wh_doc.status or "").strip().lower() != "posted":
continue
for wh_line in wh_doc.lines:
if not _warehouse_line_matches_invoice_movement(invoice_document_type, str(wh_line.movement or "")):
continue
qty = Decimal(str(wh_line.quantity or 0))
if qty <= 0:
continue
extra = wh_line.extra_info or {}
line_id = extra.get("invoice_item_line_id")
if line_id is not None:
try:
processed_by_line[int(line_id)] = processed_by_line.get(int(line_id), Decimal(0)) + qty
except (TypeError, ValueError):
orphan_by_product[wh_line.product_id] = orphan_by_product.get(wh_line.product_id, Decimal(0)) + qty
else:
orphan_by_product[wh_line.product_id] = orphan_by_product.get(wh_line.product_id, Decimal(0)) + qty
line_quantities: List[Dict[str, Any]] = []
for row in item_rows:
if not row.product_id:
continue
required = Decimal(str(row.quantity or 0))
processed = processed_by_line.get(int(row.id), Decimal(0))
remaining = required - processed
line_quantities.append(
{
"invoice_item_line_id": int(row.id),
"product_id": int(row.product_id),
"required_quantity": float(required),
"processed_quantity": float(processed),
"remaining_quantity": float(remaining) if remaining > _WAREHOUSE_QTY_EPSILON else 0.0,
}
)
for pid, orphan_qty in orphan_by_product.items():
left = orphan_qty
if left <= 0:
continue
for entry in line_quantities:
if entry["product_id"] != pid or left <= 0:
continue
req = Decimal(str(entry["required_quantity"]))
proc = Decimal(str(entry["processed_quantity"]))
space = req - proc
if space <= 0:
continue
take = min(left, space)
proc += take
left -= take
entry["processed_quantity"] = float(proc)
rem = req - proc
entry["remaining_quantity"] = float(rem) if rem > _WAREHOUSE_QTY_EPSILON else 0.0
return line_quantities
def compute_invoice_line_quantities(
db: Session,
business_id: int,
invoice: Document,
) -> List[Dict[str, Any]]:
"""مقادیر مورد نیاز، پردازش‌شده و باقی‌مانده برای هر ردیف فاکتور (بر اساس invoice_item_line_id)."""
from adapters.db.models.invoice_item_line import InvoiceItemLine
item_rows = (
db.query(InvoiceItemLine)
.filter(InvoiceItemLine.document_id == invoice.id)
.order_by(InvoiceItemLine.id.asc())
.all()
)
warehouse_docs = (
db.query(WarehouseDocument)
.options(joinedload(WarehouseDocument.lines))
.filter(
and_(
WarehouseDocument.business_id == business_id,
WarehouseDocument.source_type == "invoice",
WarehouseDocument.source_document_id == invoice.id,
)
)
.all()
)
return _compute_line_quantities_core(invoice.document_type, item_rows, warehouse_docs)
def compute_invoice_line_quantities_batch(
db: Session,
business_id: int,
invoices: List[Document],
) -> Dict[int, List[Dict[str, Any]]]:
"""محاسبه batch مقادیر خطوط برای چند فاکتور (برای لیست جستجوی حواله)."""
from adapters.db.models.invoice_item_line import InvoiceItemLine
if not invoices:
return {}
invoice_ids = [int(inv.id) for inv in invoices]
item_rows = (
db.query(InvoiceItemLine)
.filter(InvoiceItemLine.document_id.in_(invoice_ids))
.order_by(InvoiceItemLine.document_id.asc(), InvoiceItemLine.id.asc())
.all()
)
items_by_invoice: Dict[int, List[Any]] = defaultdict(list)
for row in item_rows:
items_by_invoice[int(row.document_id)].append(row)
warehouse_docs = (
db.query(WarehouseDocument)
.options(joinedload(WarehouseDocument.lines))
.filter(
and_(
WarehouseDocument.business_id == business_id,
WarehouseDocument.source_type == "invoice",
WarehouseDocument.source_document_id.in_(invoice_ids),
)
)
.all()
)
wh_by_invoice: Dict[int, List[WarehouseDocument]] = defaultdict(list)
for wh in warehouse_docs:
if wh.source_document_id is not None:
wh_by_invoice[int(wh.source_document_id)].append(wh)
result: Dict[int, List[Dict[str, Any]]] = {}
for inv in invoices:
inv_id = int(inv.id)
result[inv_id] = _compute_line_quantities_core(
inv.document_type,
items_by_invoice.get(inv_id, []),
wh_by_invoice.get(inv_id, []),
)
return result
def line_quantities_has_remaining(line_quantities: List[Dict[str, Any]]) -> bool:
"""آیا حداقل یک ردیف فاکتور مقدار باقی‌مانده برای حواله دارد؟"""
for entry in line_quantities:
if Decimal(str(entry.get("remaining_quantity") or 0)) > _WAREHOUSE_QTY_EPSILON:
return True
return False
def _active_invoice_warehouse_documents(warehouse_docs: List[WarehouseDocument]) -> List[WarehouseDocument]:
return [
wh for wh in warehouse_docs
if (wh.status or "").strip().lower() != "cancelled"
]
def resolve_invoice_warehouse_state(
warehouse_docs: List[WarehouseDocument],
line_quantities: List[Dict[str, Any]],
) -> str:
"""
وضعیت حوالهٔ فاکتور برای UI:
missing | draft | partial | posted
«posted» یعنی همهٔ مقادیر قابل‌رهگیری حواله شده و حوالهٔ پیش‌نویس باز ندارد.
«partial» یعنی حوالهٔ posted ناقص است یا همزمان پیش‌نویس باز دارد.
"""
active = _active_invoice_warehouse_documents(warehouse_docs)
if not active:
return "missing"
statuses = [(wh.status or "").strip().lower() for wh in active]
has_posted = any(st == "posted" for st in statuses)
has_draft = any(st == "draft" for st in statuses)
has_remaining = line_quantities_has_remaining(line_quantities)
if has_remaining:
if has_posted:
return "partial"
return "draft" if has_draft else "missing"
if has_draft:
return "partial"
if has_posted:
return "posted"
return "draft"
def invoice_warehouse_fulfillment_is_complete(
warehouse_docs: List[WarehouseDocument],
line_quantities: List[Dict[str, Any]],
) -> bool:
"""فاکتور از نظر مقدار حواله کامل شده و پیش‌نویس باز ندارد."""
if line_quantities_has_remaining(line_quantities):
return False
active = _active_invoice_warehouse_documents(warehouse_docs)
if not active:
return False
statuses = [(wh.status or "").strip().lower() for wh in active]
if not any(st == "posted" for st in statuses):
return False
return not any(st == "draft" for st in statuses)
def remaining_invoice_lines_for_warehouse(
db: Session,
business_id: int,
invoice: Document,
*,
line_quantities: Optional[List[Dict[str, Any]]] = None,
) -> List[Dict[str, Any]]:
"""خطوط فاکتور با مقدار باقی‌مانده برای ساخت حواله (پیش‌نویس یا دستی)."""
from adapters.db.models.invoice_item_line import InvoiceItemLine
if line_quantities is None:
line_quantities = compute_invoice_line_quantities(db, business_id, invoice)
by_line = {
int(entry["invoice_item_line_id"]): entry
for entry in line_quantities
if entry.get("invoice_item_line_id") is not None
}
item_rows = (
db.query(InvoiceItemLine)
.filter(InvoiceItemLine.document_id == invoice.id)
.order_by(InvoiceItemLine.id.asc())
.all()
)
lines: List[Dict[str, Any]] = []
for row in item_rows:
entry = by_line.get(int(row.id))
if not entry:
continue
rem = Decimal(str(entry.get("remaining_quantity") or 0))
if rem <= _WAREHOUSE_QTY_EPSILON:
continue
extra = row.extra_info or {}
warehouse_id = extra.get("warehouse_id") if isinstance(extra, dict) else None
lines.append(
{
"invoice_item_line_id": int(row.id),
"product_id": row.product_id,
"quantity": float(rem),
"warehouse_id": warehouse_id,
"extra_info": extra,
}
)
return lines
def search_invoices_for_warehouse_sources(
db: Session,
business_id: int,
invoice_types: List[str],
*,
doc_type_hint: Optional[str] = None,
search_term: str = "",
include_completed: bool = False,
skip: int = 0,
take: int = 20,
) -> Dict[str, Any]:
"""فهرست فاکتورهای قابل انتخاب برای صدور حواله با فیلتر تکمیل مبتنی بر مقدار."""
q = db.query(Document).filter(
and_(
Document.business_id == business_id,
Document.document_type.in_(invoice_types),
Document.is_proforma == False, # noqa: E712
)
)
if search_term:
q = q.filter(Document.code.ilike(f"%{search_term.strip()}%"))
ordered = q.order_by(Document.document_date.desc(), Document.id.desc())
if include_completed:
total = ordered.count()
rows = ordered.offset(skip).limit(take).all()
else:
matched: List[Document] = []
scan_offset = 0
scan_batch = max(take * 5, 50)
while True:
batch = ordered.offset(scan_offset).limit(scan_batch).all()
if not batch:
break
qty_map = compute_invoice_line_quantities_batch(db, business_id, batch)
wh_rows = (
db.query(WarehouseDocument)
.filter(
and_(
WarehouseDocument.business_id == business_id,
WarehouseDocument.source_type == "invoice",
WarehouseDocument.source_document_id.in_([int(d.id) for d in batch]),
)
)
.all()
)
wh_map: Dict[int, List[WarehouseDocument]] = defaultdict(list)
for wh in wh_rows:
if wh.source_document_id is not None:
wh_map[int(wh.source_document_id)].append(wh)
for doc in batch:
line_qty = qty_map.get(int(doc.id), [])
wh_list = wh_map.get(int(doc.id), [])
if not invoice_warehouse_fulfillment_is_complete(wh_list, line_qty):
matched.append(doc)
scan_offset += scan_batch
total = len(matched)
rows = matched[skip : skip + take]
invoice_ids = [int(doc.id) for doc in rows]
warehouse_map: Dict[int, List[WarehouseDocument]] = defaultdict(list)
if invoice_ids:
wh_rows = (
db.query(WarehouseDocument)
.filter(
and_(
WarehouseDocument.business_id == business_id,
WarehouseDocument.source_type == "invoice",
WarehouseDocument.source_document_id.in_(invoice_ids),
)
)
.all()
)
for wh in wh_rows:
if wh.source_document_id is not None:
warehouse_map[int(wh.source_document_id)].append(wh)
line_qty_map = compute_invoice_line_quantities_batch(db, business_id, rows) if rows else {}
person_ids: List[int] = []
for doc in rows:
extra = doc.extra_info or {}
pid = extra.get("person_id")
if pid:
try:
person_ids.append(int(pid))
except (TypeError, ValueError):
continue
person_ids = list({pid for pid in person_ids})
person_map: Dict[int, str] = {}
if person_ids:
person_rows = (
db.query(Person)
.filter(and_(Person.id.in_(person_ids), Person.business_id == business_id))
.all()
)
for prow in person_rows:
name = prow.alias_name
if not name:
parts = filter(None, [getattr(prow, "first_name", None), getattr(prow, "last_name", None)])
joined = " ".join(parts).strip()
name = joined or getattr(prow, "company_name", None) or ""
person_map[int(prow.id)] = name
items: List[Dict[str, Any]] = []
for doc in rows:
extra = doc.extra_info or {}
person_name = extra.get("person_name")
person_id = extra.get("person_id")
if not person_name and person_id:
try:
person_name = person_map.get(int(person_id))
except (TypeError, ValueError):
person_name = None
totals = extra.get("totals") if isinstance(extra, dict) else None
net_amount = None
if isinstance(totals, dict):
try:
net_amount = float(totals.get("net") or 0)
except (TypeError, ValueError):
net_amount = None
wh_list = warehouse_map.get(int(doc.id), [])
line_qty = line_qty_map.get(int(doc.id), [])
state = resolve_invoice_warehouse_state(wh_list, line_qty)
items.append(
{
"invoice_id": doc.id,
"code": doc.code,
"document_date": doc.document_date.isoformat(),
"invoice_type": doc.document_type,
"person_name": person_name,
"person_id": person_id,
"net_amount": net_amount,
"warehouse_state": state,
"warehouse_doc_type_hint": doc_type_hint,
"warehouse_documents": [
{
"id": wh.id,
"code": wh.code,
"status": wh.status,
"doc_type": wh.doc_type,
}
for wh in wh_list
],
}
)
return {
"items": items,
"total": total,
"take": take,
"skip": skip,
"page": (skip // take) + 1 if take else 1,
"total_pages": (total + take - 1) // take if take else 1,
}
def _remaining_quantity_for_invoice_line(
line_quantities: List[Dict[str, Any]],
invoice_item_line_id: Optional[int],
product_id: Optional[int],
) -> Decimal:
if invoice_item_line_id is not None:
for entry in line_quantities:
if entry.get("invoice_item_line_id") == int(invoice_item_line_id):
return Decimal(str(entry.get("remaining_quantity") or 0))
if product_id is not None:
for entry in line_quantities:
if entry.get("product_id") == int(product_id):
return Decimal(str(entry.get("remaining_quantity") or 0))
return Decimal(0)
def validate_invoice_warehouse_lines_quantities(
db: Session,
business_id: int,
invoice: Document,
lines: List[Dict[str, Any]],
) -> None:
"""اعتبارسنجی اینکه تعداد هر خط از باقی‌ماندهٔ همان ردیف فاکتور بیشتر نباشد."""
line_quantities = compute_invoice_line_quantities(db, business_id, invoice)
has_positive = False
for i, ln in enumerate(lines, start=1):
qty = Decimal(str(ln.get("quantity") or 0))
if qty <= 0:
continue
has_positive = True
line_id = ln.get("invoice_item_line_id")
try:
line_id_int = int(line_id) if line_id is not None else None
except (TypeError, ValueError):
line_id_int = None
pid = ln.get("product_id")
try:
pid_int = int(pid) if pid is not None else None
except (TypeError, ValueError):
pid_int = None
remaining = _remaining_quantity_for_invoice_line(line_quantities, line_id_int, pid_int)
if qty > remaining:
raise ApiError(
"QUANTITY_EXCEEDS_REMAINING",
f"خط {i}: تعداد ({qty}) از باقی‌مانده ({remaining}) بیشتر است",
http_status=400,
)
if not has_positive:
raise ApiError("NO_POSITIVE_LINES", "حداقل یک خط با تعداد مثبت لازم است", http_status=400)
def invoice_lines_have_trackable_inventory_products(
db: Session,
business_id: int,
lines: List[Dict[str, Any]],
) -> bool:
"""آیا حداقل یک ردیف فاکتور وجود دارد که در create_from_invoice به خط حواله انبار تبدیل شود؟
(همان فیلتر product معتبر، quantity مثبت، track_inventory=True)
"""
for ln in lines:
pid = ln.get("product_id")
try:
qty = Decimal(str(ln.get("quantity") or 0))
except Exception:
qty = Decimal(0)
if not pid or qty <= 0:
continue
product = db.query(Product).filter(and_(Product.id == int(pid), Product.business_id == business_id)).first()
if not product or not getattr(product, "track_inventory", False):
continue
return True
return False
def create_from_invoice(
db: Session,
business_id: int,
invoice: Document,
lines: List[Dict[str, Any]],
wh_doc_type: str,
created_by_user_id: Optional[int] = None,
extra_data: Optional[Dict[str, Any]] = None,
) -> WarehouseDocument:
"""ساخت حواله انبار draft از روی فاکتور (بدون پست)."""
fy = _get_current_fiscal_year(db, business_id)
# خواندن انبار کلی از سطح سند فاکتور (extra_info.warehouse_id)
# این انبار به سطح سند حواله منتقل می‌شود
invoice_extra_info = invoice.extra_info or {}
invoice_warehouse_id = invoice_extra_info.get("warehouse_id")
warehouse_id_from = None
warehouse_id_to = None
# تعیین انبار سطح سند حواله بر اساس نوع حواله و انبار فاکتور
if invoice_warehouse_id:
try:
invoice_warehouse_id = int(invoice_warehouse_id)
if wh_doc_type in ("issue", "production_out"):
# برای حواله خروج: انبار فاکتور به warehouse_id_from منتقل می‌شود
warehouse_id_from = invoice_warehouse_id
elif wh_doc_type in ("receipt", "production_in"):
# برای حواله ورود: انبار فاکتور به warehouse_id_to منتقل می‌شود
warehouse_id_to = invoice_warehouse_id
except (ValueError, TypeError):
pass # اگر تبدیل به int ناموفق بود، نادیده می‌گیریم
# آماده‌سازی extra_info با فیلدهای ارسال
extra_data = extra_data or {}
document_date_raw = extra_data.get("document_date")
if document_date_raw:
try:
document_date = (
date.fromisoformat(document_date_raw)
if isinstance(document_date_raw, str)
else document_date_raw
)
except Exception:
raise ApiError("INVALID_DATE", "فرمت تاریخ حواله معتبر نیست", http_status=400)
else:
# تاریخ صدور حواله؛ در صورت عدم ارسال، روز جاری (زمان عملیات صدور)
document_date = date.today()
delivery_fields = {
"description": extra_data.get("description"),
"delivery_method": extra_data.get("delivery_method"),
"carrier_name": extra_data.get("carrier_name"),
"recipient_name": extra_data.get("recipient_name"),
"recipient_phone": extra_data.get("recipient_phone"),
"tracking_number": extra_data.get("tracking_number"),
}
# حذف فیلدهای None
delivery_fields = {k: v for k, v in delivery_fields.items() if v is not None}
extra_info = delivery_fields if delivery_fields else None
# تولید code و INSERT اولیه حواله باید در برابر برخورد unique مقاوم باشد.
# از SAVEPOINT استفاده می‌کنیم تا در صورت IntegrityError فقط همین INSERT برگشت بخورد
# و تراکنش اصلی ساخت فاکتور از بین نرود.
wh: Optional[WarehouseDocument] = None
for attempt in range(10):
code = _generate_warehouse_document_code(db, business_id, document_date)
try:
with db.begin_nested():
wh = WarehouseDocument(
business_id=business_id,
fiscal_year_id=fy.id,
code=code,
document_date=document_date,
status="draft",
doc_type=wh_doc_type,
warehouse_id_from=warehouse_id_from,
warehouse_id_to=warehouse_id_to,
source_type="invoice",
source_document_id=invoice.id,
created_by_user_id=created_by_user_id,
extra_info=extra_info,
)
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", "Failed to generate unique warehouse document code", http_status=500)
validate_invoice_warehouse_lines_quantities(db, business_id, invoice, lines)
lines_added = 0
for ln in lines:
pid = ln.get("product_id")
qty = Decimal(str(ln.get("quantity") or 0))
if not pid or qty <= 0:
continue
# بررسی محصول
product = db.query(Product).filter(and_(Product.id == int(pid), Product.business_id == business_id)).first()
if not product:
continue # اگر محصول یافت نشد، خط را رد می‌کنیم
# اقلام غیرانبارداری (مثل خدمات) نباید وارد حواله انبار شوند
# در غیر این صورت ممکن است حواله‌ای با warehouse_id=None یا حرکت‌های اشتباه ساخته شود.
if not getattr(product, "track_inventory", False):
continue
extra = ln.get("extra_info") or {}
# حرکت خط حواله باید با نوع حواله (issue/receipt) هم‌خوان باشد، نه movement ذخیره‌شده در فاکتور.
mv = "out" if wh_doc_type in ("issue", "production_out") else "in"
# دریافت warehouse_id از سطح ردیف خط یا از extra_info
# منطق fallback: اگر انبار در سطح ردیف مشخص نشده باشد، از انبار سطح سند حواله استفاده می‌شود
# (که خود از extra_info.warehouse_id فاکتور آمده است)
warehouse_id = ln.get("warehouse_id") or extra.get("warehouse_id")
try:
warehouse_id = int(warehouse_id) if warehouse_id is not None else None
except Exception:
warehouse_id = None
# اگر انبار در سطح ردیف مشخص نشده، از انبار سطح سند حواله استفاده کن
if not warehouse_id:
if mv == "out":
warehouse_id = warehouse_id_from
elif mv == "in":
warehouse_id = warehouse_id_to
# برای اقلام انبارداری، warehouse_id الزامی است
if not warehouse_id:
raise ApiError("WAREHOUSE_REQUIRED", "warehouse_id برای خطوط انبارداری الزامی است", http_status=400)
# بررسی و ایجاد instance های کالای یونیک (فقط برای حواله ورود)
instance_data = ln.get("instance_data")
instance_ids = []
if instance_data and isinstance(instance_data, list) and len(instance_data) > 0:
# بررسی اینکه کالا یونیک است
if product.inventory_mode != "unique":
raise ApiError("NOT_UNIQUE_PRODUCT", f"خط با product_id {pid}: این کالا در حالت یونیک نیست", http_status=400)
# برای حواله ورود، instance ها را ایجاد می‌کنیم
if wh_doc_type in ("receipt", "production_in"):
from adapters.db.models.product_instance import ProductInstance
for inst_idx, inst_data in enumerate(instance_data, start=1):
if not isinstance(inst_data, dict):
raise ApiError("INVALID_INSTANCE_DATA", f"خط با product_id {pid}، واحد {inst_idx}: اطلاعات instance معتبر نیست", http_status=400)
serial_number = inst_data.get("serial_number")
barcode = inst_data.get("barcode")
custom_attributes = inst_data.get("custom_attributes")
if not serial_number and product.track_serial:
raise ApiError("SERIAL_REQUIRED", f"خط با product_id {pid}، واحد {inst_idx}: شماره سریال الزامی است", http_status=400)
# بررسی یکتایی سریال نامبر
if serial_number:
existing = db.query(ProductInstance).filter(
and_(
ProductInstance.business_id == business_id,
ProductInstance.serial_number == serial_number,
)
).first()
if existing:
raise ApiError("DUPLICATE_SERIAL", f"خط با product_id {pid}، واحد {inst_idx}: شماره سریال {serial_number} تکراری است", http_status=409)
# بررسی یکتایی بارکد
if barcode:
existing_barcode = db.query(ProductInstance).filter(
and_(
ProductInstance.business_id == business_id,
ProductInstance.barcode == barcode,
)
).first()
if existing_barcode:
raise ApiError("DUPLICATE_BARCODE", f"خط با product_id {pid}، واحد {inst_idx}: بارکد {barcode} تکراری است", http_status=409)
# اعتبارسنجی custom_attributes
if custom_attributes:
is_valid, error_message = validate_custom_attributes(
db=db,
business_id=business_id,
product_id=int(pid),
custom_attributes=custom_attributes
)
if not is_valid:
raise ApiError("INVALID_CUSTOM_ATTRIBUTES", f"خط با product_id {pid}، واحد {inst_idx}: {error_message or 'مقادیر ویژگی‌های کالا معتبر نیست'}", http_status=400)
# تعیین انبار - برای حواله ورود از warehouse_id استفاده می‌کنیم
instance_warehouse_id = warehouse_id if wh_doc_type in ("receipt", "production_in") else None
# ایجاد instance
instance = ProductInstance(
business_id=business_id,
product_id=int(pid),
serial_number=serial_number or f"SN-{wh.id}-{pid}-{inst_idx}", # اگر track_serial false باشد
barcode=barcode,
warehouse_id=int(instance_warehouse_id) if instance_warehouse_id else None,
status="available",
custom_attributes=custom_attributes if custom_attributes else None,
entry_date=document_date,
)
db.add(instance)
db.flush() # برای دریافت ID
instance_ids.append(instance.id)
# برای حواله خروج، instance_ids را پردازش می‌کنیم
# اول بررسی می‌کنیم که آیا selected_instance_ids از فاکتور آمده است
instance_ids_from_line = ln.get("instance_ids")
selected_instance_ids_from_invoice = extra.get("selected_instance_ids")
# اگر selected_instance_ids از فاکتور آمده و instance_ids مستقیم در خط نباشد، از selected_instance_ids استفاده می‌کنیم
if not instance_ids_from_line and selected_instance_ids_from_invoice and isinstance(selected_instance_ids_from_invoice, list) and len(selected_instance_ids_from_invoice) > 0:
instance_ids_from_line = selected_instance_ids_from_invoice
if instance_ids_from_line and isinstance(instance_ids_from_line, list) and len(instance_ids_from_line) > 0:
from adapters.db.models.product_instance import ProductInstance
# بررسی اینکه کالا یونیک است
if product.inventory_mode != "unique":
raise ApiError("NOT_UNIQUE_PRODUCT", f"خط با product_id {pid}: این کالا در حالت یونیک نیست", http_status=400)
# برای حواله خروج، instance ها را به‌روزرسانی می‌کنیم
if wh_doc_type in ("issue", "production_out"):
for inst_id in instance_ids_from_line:
instance = db.query(ProductInstance).filter(
and_(
ProductInstance.id == int(inst_id),
ProductInstance.business_id == business_id,
ProductInstance.product_id == int(pid),
ProductInstance.status == "available",
)
).first()
if not instance:
raise ApiError("INSTANCE_NOT_FOUND", f"خط با product_id {pid}: کالای یونیک با ID {inst_id} یافت نشد یا در دسترس نیست", http_status=404)
# به‌روزرسانی instance
instance.warehouse_id = None # از انبار خارج می‌شود
instance.status = "sold" # یا می‌توانیم status دیگری استفاده کنیم
instance.last_movement_date = document_date
instance_ids.append(instance.id) # برای ذخیره در خط
# پیوند به ردیف فاکتور برای شناسایی بهای تمام‌شده قطعی و گزارش‌ها
if ln.get("invoice_item_line_id") is not None:
try:
extra = dict(extra or {})
extra["invoice_item_line_id"] = int(ln["invoice_item_line_id"])
except Exception:
pass
# اضافه کردن instance_ids به extra_info
if instance_ids:
extra["instance_ids"] = instance_ids
if instance_ids_from_line and not instance_ids:
extra["instance_ids"] = instance_ids_from_line
# instance_ids برای خط (از instance_ids یا instance_ids_from_line)
line_instance_ids = instance_ids_from_line if instance_ids_from_line else (instance_ids if instance_ids else None)
# بررسی تعداد instance ها برای کالاهای یونیک
if product.inventory_mode == "unique" and line_instance_ids:
instance_count = len(line_instance_ids) if isinstance(line_instance_ids, list) else 0
if instance_count > 0:
# برای کالاهای یونیک، تعداد instance ها باید با quantity برابر باشد
if instance_count > int(qty):
raise ApiError("INSTANCE_COUNT_EXCEEDS_QUANTITY", f"خط با product_id {pid}: تعداد کالاهای یونیک ({instance_count}) نمی‌تواند از تعداد وارد شده ({int(qty)}) بیشتر باشد", http_status=400)
line_wh_loc = _warehouse_location_id_from_payload(
{"warehouse_location_id": ln.get("warehouse_location_id"), "extra_info": extra}
)
wline = WarehouseDocumentLine(
warehouse_document_id=wh.id,
product_id=int(pid),
warehouse_id=warehouse_id,
warehouse_location_id=line_wh_loc,
movement=str(mv),
quantity=qty,
extra_info=extra,
instance_ids=line_instance_ids,
)
db.add(wline)
lines_added += 1
if lines_added == 0:
db.delete(wh)
db.flush()
raise ApiError("NO_POSITIVE_LINES", "حداقل یک خط با تعداد مثبت لازم است", http_status=400)
db.flush()
return wh
def create_manual_warehouse_document(
db: Session,
business_id: int,
user_id: int,
data: Dict[str, Any],
) -> WarehouseDocument:
"""ایجاد حواله انبار دستی (بدون فاکتور)."""
from datetime import date as date_type
# اعتبارسنجی ورودی
doc_type = data.get("doc_type")
if not doc_type or doc_type not in ("receipt", "issue", "transfer", "adjustment", "production_in", "production_out"):
raise ApiError("INVALID_DOC_TYPE", "نوع حواله معتبر نیست", http_status=400)
document_date_str = data.get("document_date")
if not document_date_str:
raise ApiError("DATE_REQUIRED", "تاریخ حواله الزامی است", http_status=400)
try:
document_date = date_type.fromisoformat(document_date_str) if isinstance(document_date_str, str) else document_date_str
except Exception:
raise ApiError("INVALID_DATE", "فرمت تاریخ معتبر نیست", http_status=400)
lines_data = data.get("lines", [])
if not lines_data:
raise ApiError("LINES_REQUIRED", "حواله باید حداقل یک خط داشته باشد", http_status=400)
# بررسی انبارها برای انواع مختلف حواله
warehouse_id_from = data.get("warehouse_id_from")
warehouse_id_to = data.get("warehouse_id_to")
if doc_type == "transfer":
if not warehouse_id_from or not warehouse_id_to:
raise ApiError("WAREHOUSES_REQUIRED", "برای حواله انتقال، انبار مبدا و مقصد الزامی است", http_status=400)
if int(warehouse_id_from) == int(warehouse_id_to):
raise ApiError("INVALID_WAREHOUSES", "انبار مبدا و مقصد نمی‌توانند یکسان باشند", http_status=400)
elif doc_type in ("issue", "production_out"):
if not warehouse_id_from:
raise ApiError("WAREHOUSE_REQUIRED", "برای حواله خروج، انبار الزامی است", http_status=400)
elif doc_type in ("receipt", "production_in"):
if not warehouse_id_to:
raise ApiError("WAREHOUSE_REQUIRED", "برای حواله ورود، انبار الزامی است", http_status=400)
# بررسی انبارها در business
if warehouse_id_from:
wh_from = db.query(Warehouse).filter(and_(Warehouse.id == int(warehouse_id_from), Warehouse.business_id == business_id)).first()
if not wh_from:
raise ApiError("WAREHOUSE_NOT_FOUND", "انبار مبدا یافت نشد", http_status=404)
if warehouse_id_to:
wh_to = db.query(Warehouse).filter(and_(Warehouse.id == int(warehouse_id_to), Warehouse.business_id == business_id)).first()
if not wh_to:
raise ApiError("WAREHOUSE_NOT_FOUND", "انبار مقصد یافت نشد", http_status=404)
# بررسی سال مالی
fy = _get_current_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", f"تاریخ باید در بازه سال مالی ({fy.start_date} تا {fy.end_date or 'نامحدود'}) باشد", http_status=400)
# آماده‌سازی extra_info با فیلدهای ارسال
existing_extra_info = data.get("extra_info") or {}
if not isinstance(existing_extra_info, dict):
existing_extra_info = {}
# استخراج فیلدهای ارسال از payload
delivery_fields = {
"description": data.get("description"),
"delivery_method": data.get("delivery_method"),
"carrier_name": data.get("carrier_name"),
"recipient_name": data.get("recipient_name"),
"recipient_phone": data.get("recipient_phone"),
"tracking_number": data.get("tracking_number"),
}
# حذف فیلدهای None
delivery_fields = {k: v for k, v in delivery_fields.items() if v is not None}
# ادغام با extra_info موجود
extra_info = {**existing_extra_info, **delivery_fields}
# ایجاد حواله — تولید code و INSERT در برابر برخورد unique روی warehouse_documents.code
wh: Optional[WarehouseDocument] = None
for attempt in range(10):
code = _generate_warehouse_document_code(db, business_id, document_date)
try:
with db.begin_nested():
wh = WarehouseDocument(
business_id=business_id,
fiscal_year_id=fy.id,
code=code,
document_date=document_date,
status="draft",
doc_type=doc_type,
warehouse_id_from=int(warehouse_id_from) if warehouse_id_from else None,
warehouse_id_to=int(warehouse_id_to) if warehouse_id_to else None,
source_type="manual",
source_document_id=None,
created_by_user_id=user_id,
extra_info=extra_info if extra_info else None,
)
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", "Failed to generate unique warehouse document code", http_status=500)
# ایجاد خطوط
for i, ln in enumerate(lines_data, start=1):
pid = ln.get("product_id")
if not pid:
raise ApiError("PRODUCT_REQUIRED", f"خط {i}: شناسه محصول الزامی است", http_status=400)
qty = Decimal(str(ln.get("quantity") or 0))
if qty <= 0:
raise ApiError("INVALID_QUANTITY", f"خط {i}: تعداد باید مثبت باشد", http_status=400)
# بررسی محصول
product = db.query(Product).filter(and_(Product.id == int(pid), Product.business_id == business_id)).first()
if not product:
raise ApiError("PRODUCT_NOT_FOUND", f"خط {i}: محصول یافت نشد", http_status=404)
# تعیین movement و warehouse_id بر اساس نوع حواله با استفاده از منطق fallback
# این باید قبل از پردازش instance_data انجام شود چون برای تعیین انبار instance ها نیاز داریم
line_wh = None
if doc_type == "transfer":
# برای transfer، انبار instance ها از warehouse_id_to استفاده می‌شود
line_wh = ln.get("warehouse_id_to") or warehouse_id_to
elif doc_type in ("issue", "production_out"):
line_wh = ln.get("warehouse_id") or warehouse_id_from
elif doc_type in ("receipt", "production_in"):
line_wh = ln.get("warehouse_id") or warehouse_id_to
elif doc_type == "adjustment":
line_wh = ln.get("warehouse_id") or warehouse_id_to or warehouse_id_from
# بررسی و ایجاد instance های کالای یونیک (فقط برای حواله ورود)
instance_data = ln.get("instance_data")
instance_ids = []
if instance_data and isinstance(instance_data, list) and len(instance_data) > 0:
# بررسی اینکه کالا یونیک است
if product.inventory_mode != "unique":
raise ApiError("NOT_UNIQUE_PRODUCT", f"خط {i}: این کالا در حالت یونیک نیست", http_status=400)
# برای حواله ورود، instance ها را ایجاد می‌کنیم
if doc_type in ("receipt", "production_in"):
from adapters.db.models.product_instance import ProductInstance
from datetime import date as date_type
for inst_idx, inst_data in enumerate(instance_data, start=1):
if not isinstance(inst_data, dict):
raise ApiError("INVALID_INSTANCE_DATA", f"خط {i}، واحد {inst_idx}: اطلاعات instance معتبر نیست", http_status=400)
serial_number = inst_data.get("serial_number")
barcode = inst_data.get("barcode")
custom_attributes = inst_data.get("custom_attributes")
if not serial_number and product.track_serial:
raise ApiError("SERIAL_REQUIRED", f"خط {i}، واحد {inst_idx}: شماره سریال الزامی است", http_status=400)
# بررسی یکتایی سریال نامبر
if serial_number:
existing = db.query(ProductInstance).filter(
and_(
ProductInstance.business_id == business_id,
ProductInstance.serial_number == serial_number,
)
).first()
if existing:
raise ApiError("DUPLICATE_SERIAL", f"خط {i}، واحد {inst_idx}: شماره سریال {serial_number} تکراری است", http_status=409)
# بررسی یکتایی بارکد
if barcode:
existing_barcode = db.query(ProductInstance).filter(
and_(
ProductInstance.business_id == business_id,
ProductInstance.barcode == barcode,
)
).first()
if existing_barcode:
raise ApiError("DUPLICATE_BARCODE", f"خط {i}، واحد {inst_idx}: بارکد {barcode} تکراری است", http_status=409)
# اعتبارسنجی custom_attributes
if custom_attributes:
is_valid, error_message = validate_custom_attributes(
db=db,
business_id=business_id,
product_id=int(pid),
custom_attributes=custom_attributes
)
if not is_valid:
raise ApiError("INVALID_CUSTOM_ATTRIBUTES", f"خط {i}، واحد {inst_idx}: {error_message or 'مقادیر ویژگی‌های کالا معتبر نیست'}", http_status=400)
# تعیین انبار - برای حواله ورود از line_wh استفاده می‌کنیم
instance_warehouse_id = line_wh if doc_type in ("receipt", "production_in") else None
# ایجاد instance
instance = ProductInstance(
business_id=business_id,
product_id=int(pid),
serial_number=serial_number or f"SN-{wh.id}-{i}-{inst_idx}", # اگر track_serial false باشد
barcode=barcode,
warehouse_id=int(instance_warehouse_id) if instance_warehouse_id else None,
status="available",
custom_attributes=custom_attributes if custom_attributes else None,
entry_date=document_date,
)
db.add(instance)
db.flush() # برای دریافت ID
instance_ids.append(instance.id)
# برای حواله خروج و انتقال، instance_ids را پردازش می‌کنیم
instance_ids_from_line = ln.get("instance_ids")
if instance_ids_from_line and isinstance(instance_ids_from_line, list) and len(instance_ids_from_line) > 0:
from adapters.db.models.product_instance import ProductInstance
# بررسی اینکه کالا یونیک است
if product.inventory_mode != "unique":
raise ApiError("NOT_UNIQUE_PRODUCT", f"خط {i}: این کالا در حالت یونیک نیست", http_status=400)
# برای حواله خروج، instance ها را به‌روزرسانی می‌کنیم
if doc_type in ("issue", "production_out"):
for inst_id in instance_ids_from_line:
instance = db.query(ProductInstance).filter(
and_(
ProductInstance.id == int(inst_id),
ProductInstance.business_id == business_id,
ProductInstance.product_id == int(pid),
ProductInstance.status == "available",
)
).first()
if not instance:
raise ApiError("INSTANCE_NOT_FOUND", f"خط {i}: کالای یونیک با ID {inst_id} یافت نشد یا در دسترس نیست", http_status=404)
# به‌روزرسانی instance
instance.warehouse_id = None # از انبار خارج می‌شود
instance.status = "sold" # یا می‌توانیم status دیگری استفاده کنیم
instance.last_movement_date = document_date
instance_ids.append(instance.id) # برای ذخیره در خط
# برای حواله انتقال، instance_ids را برای استفاده بعدی ذخیره می‌کنیم
# (به‌روزرسانی انبار در زمان پست انجام می‌شود)
elif doc_type == "transfer":
# تعیین انبار مبدا (برای بررسی instance ها)
line_wh_from_temp = ln.get("warehouse_id_from") or warehouse_id_from
# بررسی وجود instance ها در انبار مبدا
for inst_id in instance_ids_from_line:
instance = db.query(ProductInstance).filter(
and_(
ProductInstance.id == int(inst_id),
ProductInstance.business_id == business_id,
ProductInstance.product_id == int(pid),
ProductInstance.status == "available",
)
).first()
if not instance:
raise ApiError("INSTANCE_NOT_FOUND", f"خط {i}: کالای یونیک با ID {inst_id} یافت نشد یا در دسترس نیست", http_status=404)
# بررسی اینکه instance در انبار مبدا است
if instance.warehouse_id != int(line_wh_from_temp):
raise ApiError("INSTANCE_WRONG_WAREHOUSE", f"خط {i}: کالای یونیک با ID {inst_id} در انبار مبدا نیست", http_status=400)
instance_ids.append(instance.id) # برای ذخیره در خط
# تعیین movement و warehouse_id بر اساس نوع حواله با استفاده از منطق fallback
# منطق fallback: اگر انبار در سطح ردیف مشخص نشده باشد، از انبار سطح سند استفاده می‌شود
if doc_type == "transfer":
# برای انتقال: یک خط out از مبدا و یک خط in به مقصد
# منطق fallback: line['warehouse_id_from'] ?? warehouse_id_from (سطح سند)
line_wh_from = ln.get("warehouse_id_from") or warehouse_id_from
line_wh_to = ln.get("warehouse_id_to") or warehouse_id_to
if not line_wh_from or not line_wh_to:
raise ApiError("WAREHOUSES_REQUIRED", f"خط {i}: برای انتقال، انبار مبدا و مقصد باید مشخص باشد (در سطح سند یا ردیف)", http_status=400)
# برای کالاهای یونیک، بررسی وجود instance_ids
if product.inventory_mode == "unique":
if not instance_ids and not instance_ids_from_line:
raise ApiError("INSTANCE_IDS_REQUIRED", f"خط {i}: برای انتقال کالای یونیک، instance_ids الزامی است", http_status=400)
# اضافه کردن instance_ids به extra_info برای transfer
extra_info_transfer = ln.get("extra_info") or {}
# استفاده از instance_ids (از instance_ids_from_line که پردازش شده) یا instance_ids_from_line مستقیم
final_instance_ids = instance_ids if instance_ids else instance_ids_from_line
if final_instance_ids:
extra_info_transfer["instance_ids"] = final_instance_ids
loc_from, loc_to = _transfer_location_pair_from_payload(ln)
# ایجاد خط خروج از مبدا
wline_out = WarehouseDocumentLine(
warehouse_document_id=wh.id,
product_id=int(pid),
warehouse_id=int(line_wh_from),
warehouse_location_id=loc_from,
movement="out",
quantity=qty,
extra_info=extra_info_transfer,
instance_ids=final_instance_ids if final_instance_ids else None,
)
db.add(wline_out)
# ایجاد خط ورود به مقصد
wline_in = WarehouseDocumentLine(
warehouse_document_id=wh.id,
product_id=int(pid),
warehouse_id=int(line_wh_to),
warehouse_location_id=loc_to,
movement="in",
quantity=qty,
extra_info=extra_info_transfer,
instance_ids=final_instance_ids if final_instance_ids else None,
)
db.add(wline_in)
else:
# برای سایر انواع: movement بر اساس doc_type
# منطق fallback برای انبار:
# - issue/production_out: line['warehouse_id'] ?? warehouse_id_from (سطح سند)
# - receipt/production_in: line['warehouse_id'] ?? warehouse_id_to (سطح سند)
# - adjustment: line['warehouse_id'] ?? warehouse_id_to ?? warehouse_id_from (سطح سند)
if doc_type in ("issue", "production_out"):
movement = "out"
# line_wh قبلاً تعریف شده است
elif doc_type in ("receipt", "production_in"):
movement = "in"
# line_wh قبلاً تعریف شده است
elif doc_type == "adjustment":
# برای تعدیل: movement از خط گرفته می‌شود
movement = ln.get("movement", "in")
if movement not in ("in", "out"):
raise ApiError("INVALID_MOVEMENT", f"خط {i}: movement باید 'in' یا 'out' باشد", http_status=400)
# line_wh قبلاً تعریف شده است
else:
movement = "in"
# line_wh قبلاً تعریف شده است
if not line_wh:
raise ApiError("WAREHOUSE_REQUIRED", f"خط {i}: انبار الزامی است (در سطح سند یا ردیف)", http_status=400)
# بررسی انبار
wh_check = db.query(Warehouse).filter(and_(Warehouse.id == int(line_wh), Warehouse.business_id == business_id)).first()
if not wh_check:
raise ApiError("WAREHOUSE_NOT_FOUND", f"خط {i}: انبار یافت نشد", http_status=404)
# اضافه کردن instance_ids به extra_info
extra_info = ln.get("extra_info") or {}
if instance_ids:
extra_info["instance_ids"] = instance_ids
if instance_ids_from_line:
extra_info["instance_ids"] = instance_ids_from_line
# instance_ids برای خط (از instance_ids یا instance_ids_from_line)
line_instance_ids = instance_ids_from_line if instance_ids_from_line else (instance_ids if instance_ids else None)
# بررسی تعداد instance ها برای کالاهای یونیک
if product.inventory_mode == "unique" and line_instance_ids:
instance_count = len(line_instance_ids) if isinstance(line_instance_ids, list) else 0
if instance_count > 0:
# برای کالاهای یونیک، تعداد instance ها باید با quantity برابر باشد
if instance_count > int(qty):
raise ApiError("INSTANCE_COUNT_EXCEEDS_QUANTITY", f"خط {i}: تعداد کالاهای یونیک ({instance_count}) نمی‌تواند از تعداد وارد شده ({int(qty)}) بیشتر باشد", http_status=400)
line_wh_loc = _warehouse_location_id_from_payload(ln)
wline = WarehouseDocumentLine(
warehouse_document_id=wh.id,
product_id=int(pid),
warehouse_id=int(line_wh),
warehouse_location_id=line_wh_loc,
movement=movement,
quantity=qty,
extra_info=extra_info,
instance_ids=line_instance_ids,
)
db.add(wline)
db.flush()
# Invalidate cache بعد از ایجاد موفق سند انبار
invalidate_warehouse_docs_cache(
business_id=business_id,
fiscal_year_id=wh.fiscal_year_id,
doc_type=wh.doc_type,
warehouse_id=wh.warehouse_id_from or wh.warehouse_id_to,
status=wh.status,
document_id=wh.id
)
# اگر transfer باشد، هر دو انبار را invalidate کن
if wh.doc_type == "transfer" and wh.warehouse_id_from and wh.warehouse_id_to:
invalidate_warehouse_docs_cache(
business_id=business_id,
fiscal_year_id=wh.fiscal_year_id,
doc_type=wh.doc_type,
warehouse_id=wh.warehouse_id_to,
status=wh.status
)
return wh
def update_warehouse_document(
db: Session,
business_id: int,
wh_id: int,
user_id: int,
data: Dict[str, Any],
) -> WarehouseDocument:
"""ویرایش حواله انبار (draft و posted)."""
from datetime import date as date_type
wh = db.query(WarehouseDocument).filter(WarehouseDocument.id == wh_id).first()
if not wh or wh.business_id != business_id:
raise ApiError("NOT_FOUND", "Warehouse document not found", http_status=404)
# حواله‌های draft و posted قابل ویرایش هستند
# حواله‌های cancelled قابل ویرایش نیستند
if wh.status == "cancelled":
raise ApiError("NOT_EDITABLE", "حواله‌های لغو شده قابل ویرایش نیستند", http_status=400)
# برای حواله‌های posted، فقط اجازه ویرایش اطلاعات کالاهای یونیک را می‌دهیم
is_posted = wh.status == "posted"
# برای حواله‌های posted، اجازه تغییر فیلدهای اصلی سند را نمی‌دهیم
if is_posted:
# بررسی اینکه آیا فیلدهای غیرمجاز ارسال شده‌اند
restricted_fields = ["document_date", "warehouse_id_from", "warehouse_id_to", "extra_info", "description", "delivery_method", "carrier_name", "recipient_name", "recipient_phone", "tracking_number"]
for field in restricted_fields:
if field in data:
raise ApiError("NOT_EDITABLE", f"برای حواله‌های قطعی شده، فقط اطلاعات کالاهای یونیک قابل ویرایش هستند. فیلد '{field}' قابل تغییر نیست", http_status=400)
# به‌روزرسانی فیلدهای اصلی (فقط برای draft)
if not is_posted and "document_date" in data:
document_date_str = data.get("document_date")
try:
document_date = date_type.fromisoformat(document_date_str) if isinstance(document_date_str, str) else document_date_str
except Exception:
raise ApiError("INVALID_DATE", "فرمت تاریخ معتبر نیست", http_status=400)
fy = _get_current_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", f"تاریخ باید در بازه سال مالی باشد", http_status=400)
wh.document_date = document_date
if not is_posted and "warehouse_id_from" in data:
wh.warehouse_id_from = int(data["warehouse_id_from"]) if data["warehouse_id_from"] else None
if wh.warehouse_id_from:
wh_check = db.query(Warehouse).filter(and_(Warehouse.id == wh.warehouse_id_from, Warehouse.business_id == business_id)).first()
if not wh_check:
raise ApiError("WAREHOUSE_NOT_FOUND", "انبار مبدا یافت نشد", http_status=404)
if not is_posted and "warehouse_id_to" in data:
wh.warehouse_id_to = int(data["warehouse_id_to"]) if data["warehouse_id_to"] else None
if wh.warehouse_id_to:
wh_check = db.query(Warehouse).filter(and_(Warehouse.id == wh.warehouse_id_to, Warehouse.business_id == business_id)).first()
if not wh_check:
raise ApiError("WAREHOUSE_NOT_FOUND", "انبار مقصد یافت نشد", http_status=404)
# به‌روزرسانی extra_info با فیلدهای ارسال (فقط برای draft)
if not is_posted and ("extra_info" in data or any(key in data for key in ["description", "delivery_method", "carrier_name", "recipient_name", "recipient_phone", "tracking_number"])):
existing_extra_info = wh.extra_info or {}
if not isinstance(existing_extra_info, dict):
existing_extra_info = {}
# اگر extra_info کامل ارسال شده، از آن استفاده کن
if "extra_info" in data and isinstance(data["extra_info"], dict):
existing_extra_info = data["extra_info"]
else:
# در غیر این صورت، فیلدهای ارسال را به extra_info اضافه کن
delivery_fields = {
"description": data.get("description"),
"delivery_method": data.get("delivery_method"),
"carrier_name": data.get("carrier_name"),
"recipient_name": data.get("recipient_name"),
"recipient_phone": data.get("recipient_phone"),
"tracking_number": data.get("tracking_number"),
}
# فقط فیلدهایی که ارسال شده‌اند را به‌روزرسانی کن
for key, value in delivery_fields.items():
if key in data:
if value is None:
existing_extra_info.pop(key, None)
else:
existing_extra_info[key] = value
wh.extra_info = existing_extra_info if existing_extra_info else None
wh.touch()
db.flush()
# به‌روزرسانی خطوط در صورت ارسال
if "lines" in data:
lines_data = data.get("lines")
if lines_data is not None:
# برای حواله‌های posted، فقط اجازه به‌روزرسانی instance_data و instance_ids را می‌دهیم
if is_posted:
# دریافت خطوط موجود
existing_lines = db.query(WarehouseDocumentLine).filter(WarehouseDocumentLine.warehouse_document_id == wh.id).all()
existing_lines_map = {line.id: line for line in existing_lines}
# شناسایی instance های قدیمی که باید حذف شوند (فقط برای حواله ورود)
old_instance_ids_to_delete = []
if wh.doc_type in ("receipt", "production_in"):
from adapters.db.models.product_instance import ProductInstance
for old_line in existing_lines:
if old_line.instance_ids and isinstance(old_line.instance_ids, list):
for inst_id in old_line.instance_ids:
try:
inst_id_int = int(inst_id) if not isinstance(inst_id, int) else inst_id
old_instance_ids_to_delete.append(inst_id_int)
except Exception:
pass
all_new_instance_ids = [] # برای نگهداری همه instance_ids جدید
# به‌روزرسانی خطوط موجود (فقط instance_data و instance_ids)
for i, ln in enumerate(lines_data, start=1):
line_id = ln.get("id")
if not line_id:
raise ApiError("LINE_ID_REQUIRED", f"خط {i}: برای حواله‌های قطعی شده، شناسه خط الزامی است", http_status=400)
existing_line = existing_lines_map.get(int(line_id))
if not existing_line:
raise ApiError("LINE_NOT_FOUND", f"خط {i}: خط با شناسه {line_id} یافت نشد", http_status=404)
# بررسی اینکه فیلدهای غیرمجاز تغییر نکرده‌اند
if "product_id" in ln and ln.get("product_id") != existing_line.product_id:
raise ApiError("NOT_EDITABLE", f"خط {i}: برای حواله‌های قطعی شده، product_id قابل تغییر نیست", http_status=400)
if "quantity" in ln and Decimal(str(ln.get("quantity") or 0)) != existing_line.quantity:
raise ApiError("NOT_EDITABLE", f"خط {i}: برای حواله‌های قطعی شده، quantity قابل تغییر نیست", http_status=400)
if "warehouse_id" in ln and ln.get("warehouse_id") != existing_line.warehouse_id:
raise ApiError("NOT_EDITABLE", f"خط {i}: برای حواله‌های قطعی شده، warehouse_id قابل تغییر نیست", http_status=400)
if "movement" in ln and ln.get("movement") != existing_line.movement:
raise ApiError("NOT_EDITABLE", f"خط {i}: برای حواله‌های قطعی شده، movement قابل تغییر نیست", http_status=400)
# استفاده از مقادیر موجود برای پردازش
pid = existing_line.product_id
qty = existing_line.quantity
movement = existing_line.movement
line_wh = existing_line.warehouse_id
# بررسی محصول
product = db.query(Product).filter(and_(Product.id == int(pid), Product.business_id == business_id)).first()
if not product:
raise ApiError("PRODUCT_NOT_FOUND", f"خط {i}: محصول یافت نشد", http_status=404)
# پردازش فقط instance_data و instance_ids
instance_data = ln.get("instance_data")
instance_ids = []
if instance_data and isinstance(instance_data, list) and len(instance_data) > 0:
# بررسی اینکه کالا یونیک است
if product.inventory_mode != "unique":
raise ApiError("NOT_UNIQUE_PRODUCT", f"خط {i}: این کالا در حالت یونیک نیست", http_status=400)
# برای حواله ورود، instance ها را ایجاد یا به‌روزرسانی می‌کنیم
if wh.doc_type in ("receipt", "production_in") and movement == "in":
from adapters.db.models.product_instance import ProductInstance
from datetime import date as date_type
document_date = wh.document_date
# دریافت instance های قبلی که به این حواله مربوط بودند
old_line_instance_ids = []
if existing_line.instance_ids and isinstance(existing_line.instance_ids, list):
old_line_instance_ids = [int(x) for x in existing_line.instance_ids if x is not None]
for inst_idx, inst_data in enumerate(instance_data, start=1):
if not isinstance(inst_data, dict):
raise ApiError("INVALID_INSTANCE_DATA", f"خط {i}، واحد {inst_idx}: اطلاعات instance معتبر نیست", http_status=400)
serial_number = inst_data.get("serial_number")
barcode = inst_data.get("barcode")
custom_attributes = inst_data.get("custom_attributes")
instance_id = inst_data.get("id") # ID instance موجود (اگر وجود داشته باشد)
if not serial_number and product.track_serial:
raise ApiError("SERIAL_REQUIRED", f"خط {i}، واحد {inst_idx}: شماره سریال الزامی است", http_status=400)
instance = None
# اگر instance قبلاً ایجاد شده (id موجود است)
if instance_id:
instance = db.query(ProductInstance).filter(
and_(
ProductInstance.id == int(instance_id),
ProductInstance.business_id == business_id,
ProductInstance.product_id == int(pid),
)
).first()
if not instance:
raise ApiError("INSTANCE_NOT_FOUND", f"خط {i}، واحد {inst_idx}: instance با ID {instance_id} یافت نشد", http_status=404)
# به‌روزرسانی instance موجود
# بررسی یکتایی سریال نامبر (فقط اگر تغییر کرده باشد)
if serial_number and instance.serial_number != serial_number:
existing_serial = db.query(ProductInstance).filter(
and_(
ProductInstance.business_id == business_id,
ProductInstance.serial_number == serial_number,
ProductInstance.id != int(instance_id),
)
).first()
if existing_serial:
raise ApiError("DUPLICATE_SERIAL", f"خط {i}، واحد {inst_idx}: شماره سریال {serial_number} تکراری است", http_status=409)
instance.serial_number = serial_number
# بررسی یکتایی بارکد (فقط اگر تغییر کرده باشد)
if barcode and instance.barcode != barcode:
existing_barcode = db.query(ProductInstance).filter(
and_(
ProductInstance.business_id == business_id,
ProductInstance.barcode == barcode,
ProductInstance.id != int(instance_id),
)
).first()
if existing_barcode:
raise ApiError("DUPLICATE_BARCODE", f"خط {i}، واحد {inst_idx}: بارکد {barcode} تکراری است", http_status=409)
instance.barcode = barcode
# به‌روزرسانی سایر فیلدها
if custom_attributes is not None:
# اعتبارسنجی custom_attributes
if custom_attributes:
is_valid, error_message = validate_custom_attributes(
db=db,
business_id=business_id,
product_id=int(pid),
custom_attributes=custom_attributes
)
if not is_valid:
raise ApiError("INVALID_CUSTOM_ATTRIBUTES", f"خط {i}، واحد {inst_idx}: {error_message or 'مقادیر ویژگی‌های کالا معتبر نیست'}", http_status=400)
instance.custom_attributes = custom_attributes if custom_attributes else None
instance_warehouse_id = line_wh if wh.doc_type in ("receipt", "production_in") else instance.warehouse_id
if instance_warehouse_id:
instance.warehouse_id = int(instance_warehouse_id)
instance_ids.append(instance.id)
else:
# ایجاد instance جدید (فقط برای حواله ورود)
# بررسی یکتایی سریال نامبر
if serial_number:
existing_serial = db.query(ProductInstance).filter(
and_(
ProductInstance.business_id == business_id,
ProductInstance.serial_number == serial_number,
)
).first()
if existing_serial:
raise ApiError("DUPLICATE_SERIAL", f"خط {i}، واحد {inst_idx}: شماره سریال {serial_number} تکراری است", http_status=409)
# بررسی یکتایی بارکد
if barcode:
existing_barcode = db.query(ProductInstance).filter(
and_(
ProductInstance.business_id == business_id,
ProductInstance.barcode == barcode,
)
).first()
if existing_barcode:
raise ApiError("DUPLICATE_BARCODE", f"خط {i}، واحد {inst_idx}: بارکد {barcode} تکراری است", http_status=409)
# اعتبارسنجی custom_attributes
if custom_attributes:
is_valid, error_message = validate_custom_attributes(
db=db,
business_id=business_id,
product_id=int(pid),
custom_attributes=custom_attributes
)
if not is_valid:
raise ApiError("INVALID_CUSTOM_ATTRIBUTES", f"خط {i}، واحد {inst_idx}: {error_message or 'مقادیر ویژگی‌های کالا معتبر نیست'}", http_status=400)
# تعیین انبار - برای حواله ورود از line_wh استفاده می‌کنیم
instance_warehouse_id = line_wh if wh.doc_type in ("receipt", "production_in") else None
# ایجاد instance جدید
instance = ProductInstance(
business_id=business_id,
product_id=int(pid),
serial_number=serial_number or f"SN-{wh.id}-{i}-{inst_idx}",
barcode=barcode,
warehouse_id=int(instance_warehouse_id) if instance_warehouse_id else None,
status="available",
custom_attributes=custom_attributes if custom_attributes else None,
entry_date=document_date,
)
db.add(instance)
db.flush()
instance_ids.append(instance.id)
# حذف instance های قدیمی که دیگر استفاده نمی‌شوند
if old_line_instance_ids:
for old_inst_id in old_line_instance_ids:
if old_inst_id not in instance_ids:
old_instance = db.query(ProductInstance).filter(
and_(
ProductInstance.id == old_inst_id,
ProductInstance.business_id == business_id,
)
).first()
if old_instance and old_instance.status == "available":
db.delete(old_instance)
# پردازش instance_ids برای حواله خروج و انتقال
instance_ids_from_line = ln.get("instance_ids")
if instance_ids_from_line and isinstance(instance_ids_from_line, list) and len(instance_ids_from_line) > 0:
from adapters.db.models.product_instance import ProductInstance
# بررسی اینکه کالا یونیک است
if product.inventory_mode != "unique":
raise ApiError("NOT_UNIQUE_PRODUCT", f"خط {i}: این کالا در حالت یونیک نیست", http_status=400)
# برای حواله خروج، فقط بررسی می‌کنیم که instance ها موجود باشند
if wh.doc_type in ("issue", "production_out") and movement == "out":
for inst_id in instance_ids_from_line:
instance = db.query(ProductInstance).filter(
and_(
ProductInstance.id == int(inst_id),
ProductInstance.business_id == business_id,
ProductInstance.product_id == int(pid),
)
).first()
if not instance:
raise ApiError("INSTANCE_NOT_FOUND", f"خط {i}: کالای یونیک با ID {inst_id} یافت نشد", http_status=404)
instance_ids.append(instance.id)
# برای حواله انتقال، بررسی می‌کنیم
elif wh.doc_type == "transfer":
line_wh_from_temp = existing_line.warehouse_id # استفاده از انبار موجود
for inst_id in instance_ids_from_line:
instance = db.query(ProductInstance).filter(
and_(
ProductInstance.id == int(inst_id),
ProductInstance.business_id == business_id,
ProductInstance.product_id == int(pid),
ProductInstance.status == "available",
)
).first()
if not instance:
raise ApiError("INSTANCE_NOT_FOUND", f"خط {i}: کالای یونیک با ID {inst_id} یافت نشد یا در دسترس نیست", http_status=404)
if instance.warehouse_id != int(line_wh_from_temp):
raise ApiError("INSTANCE_WRONG_WAREHOUSE", f"خط {i}: کالای یونیک با ID {inst_id} در انبار مبدا نیست", http_status=400)
instance_ids.append(instance.id)
# اضافه کردن instance_ids به extra_info
extra_info = existing_line.extra_info or {}
final_instance_ids = instance_ids if instance_ids else instance_ids_from_line
# بررسی تعداد instance ها برای کالاهای یونیک
if product.inventory_mode == "unique" and final_instance_ids:
instance_count = len(final_instance_ids) if isinstance(final_instance_ids, list) else 0
if instance_count > 0:
if instance_count > int(qty):
raise ApiError("INSTANCE_COUNT_EXCEEDS_QUANTITY", f"خط {i}: تعداد کالاهای یونیک ({instance_count}) نمی‌تواند از تعداد وارد شده ({int(qty)}) بیشتر باشد", http_status=400)
if final_instance_ids:
extra_info["instance_ids"] = final_instance_ids
if isinstance(final_instance_ids, list):
all_new_instance_ids.extend([int(x) for x in final_instance_ids if x is not None])
# به‌روزرسانی خط موجود (فقط instance_ids)
existing_line.instance_ids = final_instance_ids if final_instance_ids else existing_line.instance_ids
existing_line.extra_info = extra_info
# حذف instance های قدیمی که دیگر استفاده نمی‌شوند (فقط برای حواله ورود)
if wh.doc_type in ("receipt", "production_in") and old_instance_ids_to_delete:
from adapters.db.models.product_instance import ProductInstance
for old_inst_id in old_instance_ids_to_delete:
if old_inst_id not in all_new_instance_ids:
old_instance = db.query(ProductInstance).filter(
and_(
ProductInstance.id == old_inst_id,
ProductInstance.business_id == business_id,
ProductInstance.status == "available",
)
).first()
if old_instance:
db.delete(old_instance)
db.flush()
else:
# برای حواله‌های draft، منطق قبلی (حذف و ایجاد مجدد خطوط)
# دریافت خطوط قدیمی قبل از حذف
old_lines = db.query(WarehouseDocumentLine).filter(WarehouseDocumentLine.warehouse_document_id == wh.id).all()
# شناسایی instance های قدیمی که باید حذف شوند (فقط برای حواله ورود)
old_instance_ids_to_delete = []
if wh.doc_type in ("receipt", "production_in"):
from adapters.db.models.product_instance import ProductInstance
for old_line in old_lines:
if old_line.instance_ids and isinstance(old_line.instance_ids, list):
for inst_id in old_line.instance_ids:
try:
inst_id_int = int(inst_id) if not isinstance(inst_id, int) else inst_id
old_instance_ids_to_delete.append(inst_id_int)
except Exception:
pass
# حذف خطوط قدیمی
db.query(WarehouseDocumentLine).filter(WarehouseDocumentLine.warehouse_document_id == wh.id).delete()
db.flush()
# ایجاد خطوط جدید
all_new_instance_ids = [] # برای نگهداری همه instance_ids جدید
lines_added = 0
invoice_for_validation = None
if wh.source_type == "invoice" and wh.source_document_id:
invoice_for_validation = (
db.query(Document)
.filter(
and_(
Document.id == int(wh.source_document_id),
Document.business_id == business_id,
)
)
.first()
)
if invoice_for_validation is not None:
validate_invoice_warehouse_lines_quantities(
db, business_id, invoice_for_validation, lines_data
)
for i, ln in enumerate(lines_data, start=1):
pid = ln.get("product_id")
if not pid:
raise ApiError("PRODUCT_REQUIRED", f"خط {i}: شناسه محصول الزامی است", http_status=400)
qty = Decimal(str(ln.get("quantity") or 0))
if qty <= 0:
if wh.source_type == "invoice":
continue
raise ApiError("INVALID_QUANTITY", f"خط {i}: تعداد باید مثبت باشد", http_status=400)
# بررسی محصول
product = db.query(Product).filter(and_(Product.id == int(pid), Product.business_id == business_id)).first()
if not product:
raise ApiError("PRODUCT_NOT_FOUND", f"خط {i}: محصول یافت نشد", http_status=404)
movement = ln.get("movement", "in")
if movement not in ("in", "out"):
raise ApiError("INVALID_MOVEMENT", f"خط {i}: movement باید 'in' یا 'out' باشد", http_status=400)
# منطق fallback برای انبار: اگر انبار در سطح ردیف مشخص نشده باشد، از انبار سطح سند استفاده می‌شود
# - برای movement="in": line['warehouse_id'] ?? wh.warehouse_id_to (سطح سند)
# - برای movement="out": line['warehouse_id'] ?? wh.warehouse_id_from (سطح سند)
line_wh = ln.get("warehouse_id")
if not line_wh:
# اگر انبار در خط مشخص نشده، از حواله استفاده کن (fallback به سطح سند)
line_wh = wh.warehouse_id_to if movement == "in" else wh.warehouse_id_from
if not line_wh:
raise ApiError("WAREHOUSE_REQUIRED", f"خط {i}: انبار الزامی است (در سطح سند یا ردیف)", http_status=400)
# بررسی انبار
wh_check = db.query(Warehouse).filter(and_(Warehouse.id == int(line_wh), Warehouse.business_id == business_id)).first()
if not wh_check:
raise ApiError("WAREHOUSE_NOT_FOUND", f"خط {i}: انبار یافت نشد", http_status=404)
# پردازش instance_data و instance_ids برای کالاهای یونیک
instance_data = ln.get("instance_data")
instance_ids = []
if instance_data and isinstance(instance_data, list) and len(instance_data) > 0:
# بررسی اینکه کالا یونیک است
if product.inventory_mode != "unique":
raise ApiError("NOT_UNIQUE_PRODUCT", f"خط {i}: این کالا در حالت یونیک نیست", http_status=400)
# برای حواله ورود، instance ها را ایجاد یا به‌روزرسانی می‌کنیم
if wh.doc_type in ("receipt", "production_in") and movement == "in":
from adapters.db.models.product_instance import ProductInstance
from datetime import date as date_type
document_date = wh.document_date
# دریافت instance های قبلی که به این حواله مربوط بودند
old_line_instance_ids = []
if ln.get("instance_ids") and isinstance(ln.get("instance_ids"), list):
old_line_instance_ids = [int(x) for x in ln.get("instance_ids") if x is not None]
for inst_idx, inst_data in enumerate(instance_data, start=1):
if not isinstance(inst_data, dict):
raise ApiError("INVALID_INSTANCE_DATA", f"خط {i}، واحد {inst_idx}: اطلاعات instance معتبر نیست", http_status=400)
serial_number = inst_data.get("serial_number")
barcode = inst_data.get("barcode")
custom_attributes = inst_data.get("custom_attributes")
instance_id = inst_data.get("id") # ID instance موجود (اگر وجود داشته باشد)
if not serial_number and product.track_serial:
raise ApiError("SERIAL_REQUIRED", f"خط {i}، واحد {inst_idx}: شماره سریال الزامی است", http_status=400)
instance = None
# اگر instance قبلاً ایجاد شده (id موجود است)
if instance_id:
instance = db.query(ProductInstance).filter(
and_(
ProductInstance.id == int(instance_id),
ProductInstance.business_id == business_id,
ProductInstance.product_id == int(pid),
)
).first()
if not instance:
raise ApiError("INSTANCE_NOT_FOUND", f"خط {i}، واحد {inst_idx}: instance با ID {instance_id} یافت نشد", http_status=404)
# به‌روزرسانی instance موجود
# بررسی یکتایی سریال نامبر (فقط اگر تغییر کرده باشد)
if serial_number and instance.serial_number != serial_number:
existing_serial = db.query(ProductInstance).filter(
and_(
ProductInstance.business_id == business_id,
ProductInstance.serial_number == serial_number,
ProductInstance.id != int(instance_id),
)
).first()
if existing_serial:
raise ApiError("DUPLICATE_SERIAL", f"خط {i}، واحد {inst_idx}: شماره سریال {serial_number} تکراری است", http_status=409)
instance.serial_number = serial_number
# بررسی یکتایی بارکد (فقط اگر تغییر کرده باشد)
if barcode and instance.barcode != barcode:
existing_barcode = db.query(ProductInstance).filter(
and_(
ProductInstance.business_id == business_id,
ProductInstance.barcode == barcode,
ProductInstance.id != int(instance_id),
)
).first()
if existing_barcode:
raise ApiError("DUPLICATE_BARCODE", f"خط {i}، واحد {inst_idx}: بارکد {barcode} تکراری است", http_status=409)
instance.barcode = barcode
# به‌روزرسانی سایر فیلدها
if custom_attributes is not None:
# اعتبارسنجی custom_attributes
if custom_attributes:
is_valid, error_message = validate_custom_attributes(
db=db,
business_id=business_id,
product_id=int(pid),
custom_attributes=custom_attributes
)
if not is_valid:
raise ApiError("INVALID_CUSTOM_ATTRIBUTES", f"خط {i}، واحد {inst_idx}: {error_message or 'مقادیر ویژگی‌های کالا معتبر نیست'}", http_status=400)
instance.custom_attributes = custom_attributes if custom_attributes else None
instance_warehouse_id = line_wh if wh.doc_type in ("receipt", "production_in") else instance.warehouse_id
if instance_warehouse_id:
instance.warehouse_id = int(instance_warehouse_id)
instance_ids.append(instance.id)
else:
# ایجاد instance جدید
# بررسی یکتایی سریال نامبر
if serial_number:
existing_serial = db.query(ProductInstance).filter(
and_(
ProductInstance.business_id == business_id,
ProductInstance.serial_number == serial_number,
)
).first()
if existing_serial:
raise ApiError("DUPLICATE_SERIAL", f"خط {i}، واحد {inst_idx}: شماره سریال {serial_number} تکراری است", http_status=409)
# بررسی یکتایی بارکد
if barcode:
existing_barcode = db.query(ProductInstance).filter(
and_(
ProductInstance.business_id == business_id,
ProductInstance.barcode == barcode,
)
).first()
if existing_barcode:
raise ApiError("DUPLICATE_BARCODE", f"خط {i}، واحد {inst_idx}: بارکد {barcode} تکراری است", http_status=409)
# اعتبارسنجی custom_attributes
if custom_attributes:
is_valid, error_message = validate_custom_attributes(
db=db,
business_id=business_id,
product_id=int(pid),
custom_attributes=custom_attributes
)
if not is_valid:
raise ApiError("INVALID_CUSTOM_ATTRIBUTES", f"خط {i}، واحد {inst_idx}: {error_message or 'مقادیر ویژگی‌های کالا معتبر نیست'}", http_status=400)
# تعیین انبار - برای حواله ورود از line_wh استفاده می‌کنیم
instance_warehouse_id = line_wh if wh.doc_type in ("receipt", "production_in") else None
# ایجاد instance جدید
instance = ProductInstance(
business_id=business_id,
product_id=int(pid),
serial_number=serial_number or f"SN-{wh.id}-{i}-{inst_idx}", # اگر track_serial false باشد
barcode=barcode,
warehouse_id=int(instance_warehouse_id) if instance_warehouse_id else None,
status="available",
custom_attributes=custom_attributes if custom_attributes else None,
entry_date=document_date,
)
db.add(instance)
db.flush() # برای دریافت ID
instance_ids.append(instance.id)
# حذف instance های قدیمی که دیگر استفاده نمی‌شوند
if old_line_instance_ids:
for old_inst_id in old_line_instance_ids:
if old_inst_id not in instance_ids:
# این instance دیگر استفاده نمی‌شود - حذف می‌کنیم
old_instance = db.query(ProductInstance).filter(
and_(
ProductInstance.id == old_inst_id,
ProductInstance.business_id == business_id,
)
).first()
if old_instance and old_instance.status == "available":
# فقط اگر instance در دسترس است، حذف می‌کنیم
# (اگر instance به فروش رفته یا استفاده شده، نباید حذف شود)
db.delete(old_instance)
# پردازش instance_ids برای حواله خروج و انتقال
instance_ids_from_line = ln.get("instance_ids")
if instance_ids_from_line and isinstance(instance_ids_from_line, list) and len(instance_ids_from_line) > 0:
from adapters.db.models.product_instance import ProductInstance
# بررسی اینکه کالا یونیک است
if product.inventory_mode != "unique":
raise ApiError("NOT_UNIQUE_PRODUCT", f"خط {i}: این کالا در حالت یونیک نیست", http_status=400)
# برای حواله خروج، instance ها را به‌روزرسانی می‌کنیم (در زمان پست)
if wh.doc_type in ("issue", "production_out") and movement == "out":
for inst_id in instance_ids_from_line:
instance = db.query(ProductInstance).filter(
and_(
ProductInstance.id == int(inst_id),
ProductInstance.business_id == business_id,
ProductInstance.product_id == int(pid),
ProductInstance.status == "available",
)
).first()
if not instance:
raise ApiError("INSTANCE_NOT_FOUND", f"خط {i}: کالای یونیک با ID {inst_id} یافت نشد یا در دسترس نیست", http_status=404)
instance_ids.append(instance.id)
# برای حواله انتقال، instance_ids را برای استفاده بعدی ذخیره می‌کنیم
elif wh.doc_type == "transfer":
# تعیین انبار مبدا (برای بررسی instance ها)
line_wh_from_temp = ln.get("warehouse_id_from") or wh.warehouse_id_from
# بررسی وجود instance ها در انبار مبدا
for inst_id in instance_ids_from_line:
instance = db.query(ProductInstance).filter(
and_(
ProductInstance.id == int(inst_id),
ProductInstance.business_id == business_id,
ProductInstance.product_id == int(pid),
ProductInstance.status == "available",
)
).first()
if not instance:
raise ApiError("INSTANCE_NOT_FOUND", f"خط {i}: کالای یونیک با ID {inst_id} یافت نشد یا در دسترس نیست", http_status=404)
# بررسی اینکه instance در انبار مبدا است
if instance.warehouse_id != int(line_wh_from_temp):
raise ApiError("INSTANCE_WRONG_WAREHOUSE", f"خط {i}: کالای یونیک با ID {inst_id} در انبار مبدا نیست", http_status=400)
instance_ids.append(instance.id)
# اضافه کردن instance_ids به extra_info
extra_info = ln.get("extra_info") or {}
if ln.get("invoice_item_line_id") is not None:
try:
extra_info = dict(extra_info)
extra_info["invoice_item_line_id"] = int(ln["invoice_item_line_id"])
except Exception:
pass
final_instance_ids = instance_ids if instance_ids else instance_ids_from_line
# بررسی تعداد instance ها برای کالاهای یونیک
if product.inventory_mode == "unique" and final_instance_ids:
instance_count = len(final_instance_ids) if isinstance(final_instance_ids, list) else 0
if instance_count > 0:
# برای کالاهای یونیک، تعداد instance ها باید با quantity برابر باشد
if instance_count > int(qty):
raise ApiError("INSTANCE_COUNT_EXCEEDS_QUANTITY", f"خط {i}: تعداد کالاهای یونیک ({instance_count}) نمی‌تواند از تعداد وارد شده ({int(qty)}) بیشتر باشد", http_status=400)
if final_instance_ids:
extra_info["instance_ids"] = final_instance_ids
# اضافه کردن به لیست کلی instance های جدید
if isinstance(final_instance_ids, list):
all_new_instance_ids.extend([int(x) for x in final_instance_ids if x is not None])
line_wh_loc = _warehouse_location_id_from_payload(ln)
wline = WarehouseDocumentLine(
warehouse_document_id=wh.id,
product_id=int(pid),
warehouse_id=int(line_wh),
warehouse_location_id=line_wh_loc,
movement=movement,
quantity=qty,
extra_info=extra_info,
instance_ids=final_instance_ids if final_instance_ids else None,
)
db.add(wline)
lines_added += 1
if lines_added == 0:
raise ApiError("NO_POSITIVE_LINES", "حداقل یک خط با تعداد مثبت لازم است", http_status=400)
# حذف instance های قدیمی که دیگر استفاده نمی‌شوند (فقط برای حواله ورود)
if wh.doc_type in ("receipt", "production_in") and old_instance_ids_to_delete:
from adapters.db.models.product_instance import ProductInstance
for old_inst_id in old_instance_ids_to_delete:
if old_inst_id not in all_new_instance_ids:
# این instance دیگر استفاده نمی‌شود - حذف می‌کنیم
old_instance = db.query(ProductInstance).filter(
and_(
ProductInstance.id == old_inst_id,
ProductInstance.business_id == business_id,
ProductInstance.status == "available",
)
).first()
if old_instance:
# فقط اگر instance در دسترس است، حذف می‌کنیم
# (اگر instance به فروش رفته یا استفاده شده، نباید حذف شود)
db.delete(old_instance)
db.flush()
# دریافت اطلاعات قبل از به‌روزرسانی برای invalidation
old_fiscal_year_id = wh.fiscal_year_id
old_doc_type = wh.doc_type
old_warehouse_id_from = wh.warehouse_id_from
old_warehouse_id_to = wh.warehouse_id_to
old_status = wh.status
# Invalidate cache بعد از به‌روزرسانی موفق سند انبار
invalidate_warehouse_docs_cache(
business_id=business_id,
fiscal_year_id=old_fiscal_year_id,
doc_type=old_doc_type,
warehouse_id=old_warehouse_id_from or old_warehouse_id_to,
status=old_status,
document_id=wh.id
)
# اگر transfer باشد، هر دو انبار را invalidate کن
if old_doc_type == "transfer" and old_warehouse_id_from and old_warehouse_id_to:
invalidate_warehouse_docs_cache(
business_id=business_id,
fiscal_year_id=old_fiscal_year_id,
doc_type=old_doc_type,
warehouse_id=old_warehouse_id_to,
status=old_status
)
# اگر fiscal_year_id، doc_type، warehouse_id یا status تغییر کرده، cache جدید را هم invalidate کن
if wh.fiscal_year_id != old_fiscal_year_id or wh.doc_type != old_doc_type or wh.status != old_status:
invalidate_warehouse_docs_cache(
business_id=business_id,
fiscal_year_id=wh.fiscal_year_id,
doc_type=wh.doc_type,
warehouse_id=wh.warehouse_id_from or wh.warehouse_id_to,
status=wh.status,
document_id=wh.id
)
if wh.doc_type == "transfer" and wh.warehouse_id_from and wh.warehouse_id_to:
invalidate_warehouse_docs_cache(
business_id=business_id,
fiscal_year_id=wh.fiscal_year_id,
doc_type=wh.doc_type,
warehouse_id=wh.warehouse_id_to,
status=wh.status
)
return wh
def update_warehouse_document_line(
db: Session,
business_id: int,
wh_id: int,
line_id: int,
data: Dict[str, Any],
) -> WarehouseDocumentLine:
"""به‌روزرسانی یک خط حواله (مثلاً برای تعیین انبار)."""
wh = db.query(WarehouseDocument).filter(WarehouseDocument.id == wh_id).first()
if not wh or wh.business_id != business_id:
raise ApiError("NOT_FOUND", "Warehouse document not found", http_status=404)
if wh.status != "draft":
raise ApiError("NOT_EDITABLE", "فقط حواله‌های draft قابل ویرایش هستند", http_status=400)
wline = db.query(WarehouseDocumentLine).filter(
and_(
WarehouseDocumentLine.id == line_id,
WarehouseDocumentLine.warehouse_document_id == wh_id
)
).first()
if not wline:
raise ApiError("LINE_NOT_FOUND", "خط حواله یافت نشد", http_status=404)
# به‌روزرسانی فیلدها
if "warehouse_id" in data:
warehouse_id = data.get("warehouse_id")
if warehouse_id:
wh_check = db.query(Warehouse).filter(and_(Warehouse.id == int(warehouse_id), Warehouse.business_id == business_id)).first()
if not wh_check:
raise ApiError("WAREHOUSE_NOT_FOUND", "انبار یافت نشد", http_status=404)
wline.warehouse_id = int(warehouse_id)
else:
wline.warehouse_id = None
if "quantity" in data:
qty = Decimal(str(data.get("quantity") or 0))
if qty <= 0:
raise ApiError("INVALID_QUANTITY", "تعداد باید مثبت باشد", http_status=400)
wline.quantity = qty
if "movement" in data:
movement = data.get("movement")
if movement not in ("in", "out"):
raise ApiError("INVALID_MOVEMENT", "movement باید 'in' یا 'out' باشد", http_status=400)
wline.movement = movement
if "extra_info" in data:
wline.extra_info = data.get("extra_info")
if "warehouse_location_id" in data:
from app.services.warehouse_placement_sync import validate_warehouse_location_for_line
raw_loc = data.get("warehouse_location_id")
if raw_loc is None or raw_loc == "":
wline.warehouse_location_id = None
else:
try:
lid = int(raw_loc)
except Exception:
raise ApiError("INVALID_LOCATION", "شناسه محل انبار نامعتبر است", http_status=400)
if wline.warehouse_id:
validate_warehouse_location_for_line(db, business_id, int(wline.warehouse_id), lid)
wline.warehouse_location_id = lid
wh.touch()
db.flush()
return wline
def post_warehouse_document(
db: Session,
wh_id: int,
*,
stock_exclude_warehouse_document_ids: Optional[List[int]] = None,
) -> Dict[str, Any]:
"""پست حواله: کنترل کسری برای خروج‌ها و به‌روزرسانی موجودی انبار.
اگر doc_type='transfer' باشد، یک سند حسابداری هم ایجاد می‌شود.
توجه: محاسبات COGS و ثبت سطرهای حسابداری در بخش فاکتورها انجام می‌شود.
"""
from app.services.invoice_service import (
_ensure_stock_sufficient,
_get_current_fiscal_year,
filter_outgoing_lines_for_stock_enforcement,
)
from adapters.db.models.business import Business
from adapters.db.models.document import Document
from adapters.db.models.document_line import DocumentLine
from adapters.db.models.currency import Currency
from app.services.document_monetization_service import ensure_document_policy_allows_creation
from datetime import datetime
wh = db.query(WarehouseDocument).filter(WarehouseDocument.id == wh_id).first()
if not wh:
raise ApiError("NOT_FOUND", "Warehouse document not found", http_status=404)
if wh.status == "posted":
return {"id": wh.id, "status": wh.status}
if (wh.status or "").strip().lower() == "cancelled":
raise ApiError(
"NOT_POSTABLE",
"حوالهٔ لغوشده قابل قطعی‌سازی نیست",
http_status=400,
)
assert_cancel_reversal_postable(db, wh)
lines = db.query(WarehouseDocumentLine).filter(WarehouseDocumentLine.warehouse_document_id == wh.id).all()
if not lines:
raise ApiError("NO_LINES", "حواله باید حداقل یک خط داشته باشد", http_status=400)
# کنترل کسری برای خروج‌ها
outgoing_lines = []
for ln in lines:
if ln.movement == "out":
if not ln.quantity or Decimal(str(ln.quantity)) <= 0:
raise ApiError("INVALID_QUANTITY", "تعداد باید مثبت باشد", http_status=400)
if not ln.warehouse_id:
raise ApiError("WAREHOUSE_REQUIRED", "برای خطوط خروج، انبار باید مشخص باشد", http_status=400)
# بررسی اینکه محصول کنترل موجودی دارد یا نه
product = db.query(Product).filter(Product.id == ln.product_id).first()
if product and product.track_inventory:
outgoing_lines.append({
"product_id": ln.product_id,
"quantity": float(ln.quantity),
"extra_info": {
"warehouse_id": ln.warehouse_id,
"movement": "out",
"inventory_tracked": True,
},
})
# کنترل کسری موجودی (با رعایت سیاست کسب‌وکار: فله / یونیک / انتقال)
if outgoing_lines:
biz = db.query(Business).filter(Business.id == int(wh.business_id)).first()
allow_bulk = bool(getattr(biz, "allow_negative_inventory_for_bulk", False)) if biz else False
allow_unique = bool(getattr(biz, "allow_negative_inventory_for_unique", False)) if biz else False
transfer_strict = bool(getattr(biz, "warehouse_transfer_require_positive_stock", True)) if biz else True
lines_to_check = filter_outgoing_lines_for_stock_enforcement(
db,
int(wh.business_id),
outgoing_lines,
allow_negative_for_bulk=allow_bulk,
allow_negative_for_unique=allow_unique,
warehouse_doc_type=getattr(wh, "doc_type", None),
transfer_require_positive_stock=transfer_strict,
)
if lines_to_check:
exclude_fin_doc: Optional[int] = None
exclude_inv_src: Optional[int] = None
if wh.source_document_id is not None:
src_id = int(wh.source_document_id)
st = (wh.source_type or "").strip().lower()
if st == "invoice":
exclude_fin_doc = src_id
exclude_inv_src = src_id
else:
# حواله‌های قدیمی: source_type خالی یا نادرست ولی سند مبدا فاکتور است
_src_row = (
db.query(Document.document_type)
.filter(
Document.id == src_id,
Document.business_id == int(wh.business_id),
)
.first()
)
_sdt = _src_row[0] if _src_row is not None else None
if _sdt is not None and str(_sdt).startswith("invoice_"):
exclude_fin_doc = src_id
exclude_inv_src = src_id
_ex_wh: Optional[List[int]] = None
if stock_exclude_warehouse_document_ids:
_ex_wh = sorted(
{int(x) for x in stock_exclude_warehouse_document_ids if x is not None}
)
if not _ex_wh:
_ex_wh = None
try:
_ensure_stock_sufficient(
db,
wh.business_id,
wh.document_date,
lines_to_check,
exclude_document_id=exclude_fin_doc,
exclude_invoice_source_document_id=exclude_inv_src,
exclude_warehouse_document_ids=_ex_wh,
)
except ApiError:
raise
# برای حواله‌های transfer، به‌روزرسانی instance های کالاهای یونیک
if wh.doc_type == "transfer":
from adapters.db.models.product_instance import ProductInstance
# برای هر خط out، instance ها را پیدا کرده و به انبار مقصد منتقل کن
for ln_out in lines:
if ln_out.movement == "out" and ln_out.instance_ids:
# پیدا کردن خط in متناظر
ln_in = next(
(ln for ln in lines if ln.movement == "in" and ln.product_id == ln_out.product_id),
None
)
if not ln_in:
continue # اگر خط in پیدا نشد، ادامه بده
# به‌روزرسانی instance ها
if isinstance(ln_out.instance_ids, list):
for inst_id in ln_out.instance_ids:
try:
inst_id_int = int(inst_id) if not isinstance(inst_id, int) else inst_id
instance = db.query(ProductInstance).filter(
and_(
ProductInstance.id == inst_id_int,
ProductInstance.business_id == wh.business_id,
)
).first()
if instance:
# انتقال instance از انبار مبدا به انبار مقصد
instance.warehouse_id = ln_in.warehouse_id
instance.last_movement_date = wh.document_date
# status را available نگه می‌داریم (چون فقط انتقال است)
except Exception:
# در صورت خطا، ادامه بده
pass
# همگام‌سازی قرارگیری فیزیکی با خطوط دارای محل انبار (قبل از قطعی شدن سند)
from app.services.warehouse_placement_sync import apply_placement_effects_for_posted_document
apply_placement_effects_for_posted_document(db, int(wh.business_id), wh, lines)
# دریافت اطلاعات قبل از تغییر status برای invalidation
old_status = wh.status
business_id = wh.business_id
fiscal_year_id = wh.fiscal_year_id
doc_type = wh.doc_type
warehouse_id_from = wh.warehouse_id_from
warehouse_id_to = wh.warehouse_id_to
# تغییر وضعیت به posted
wh.status = "posted"
wh.touch()
db.flush()
# Invalidate cache بعد از تغییر status به posted
invalidate_warehouse_docs_cache(
business_id=business_id,
fiscal_year_id=fiscal_year_id,
doc_type=doc_type,
warehouse_id=warehouse_id_from or warehouse_id_to,
status=old_status,
document_id=wh.id
)
invalidate_warehouse_docs_cache(
business_id=business_id,
fiscal_year_id=fiscal_year_id,
doc_type=doc_type,
warehouse_id=warehouse_id_from or warehouse_id_to,
status="posted",
document_id=wh.id
)
# اگر transfer باشد، هر دو انبار را invalidate کن
if doc_type == "transfer" and warehouse_id_from and warehouse_id_to:
invalidate_warehouse_docs_cache(
business_id=business_id,
fiscal_year_id=fiscal_year_id,
doc_type=doc_type,
warehouse_id=warehouse_id_to,
status=old_status
)
invalidate_warehouse_docs_cache(
business_id=business_id,
fiscal_year_id=fiscal_year_id,
doc_type=doc_type,
warehouse_id=warehouse_id_to,
status="posted"
)
# اگر doc_type='transfer' باشد، یک سند حسابداری ایجاد کن
if wh.doc_type == "transfer":
# دریافت currency_id (از business یا default)
currency = db.query(Currency).filter(Currency.business_id == wh.business_id).first()
if not currency:
# استفاده از currency پیش‌فرض
currency = db.query(Currency).filter(Currency.code == "IRR").first()
if not currency:
raise ApiError("CURRENCY_NOT_FOUND", "Currency not found", http_status=404)
fiscal_year = _get_current_fiscal_year(db, wh.business_id)
# ساخت کد سند (مشابه inventory_transfer_service)
today = datetime.now().date()
prefix = f"ITR-{today.strftime('%Y%m%d')}"
last_doc = db.query(Document).filter(
and_(
Document.business_id == wh.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
doc_code = f"{prefix}-{next_num:04d}"
# بررسی policy
ensure_document_policy_allows_creation(
db,
wh.business_id,
document_type="inventory_transfer",
document_date=wh.document_date,
amount=Decimal(0),
)
# ایجاد سند حسابداری
accounting_doc = Document(
business_id=wh.business_id,
fiscal_year_id=fiscal_year.id,
code=doc_code,
document_type="inventory_transfer",
document_date=wh.document_date,
currency_id=currency.id,
created_by_user_id=wh.created_by_user_id,
registered_at=datetime.utcnow(),
is_proforma=False,
description=(wh.extra_info.get("description") if wh.extra_info and isinstance(wh.extra_info, dict) else None),
extra_info={
"source": "warehouse_document",
"warehouse_document_id": wh.id,
},
)
db.add(accounting_doc)
db.flush()
# ایجاد خطوط حسابداری از خطوط حواله
# برای transfer: یک خط out از مبدا و یک خط in به مقصد
for wline in lines:
if wline.movement == "out":
# خط خروج از انبار مبدا
db.add(DocumentLine(
document_id=accounting_doc.id,
product_id=wline.product_id,
quantity=wline.quantity,
debit=Decimal(0),
credit=Decimal(0),
description=None,
extra_info={
"movement": "out",
"warehouse_id": wline.warehouse_id,
"inventory_tracked": True,
},
))
elif wline.movement == "in":
# خط ورود به انبار مقصد
db.add(DocumentLine(
document_id=accounting_doc.id,
product_id=wline.product_id,
quantity=wline.quantity,
debit=Decimal(0),
credit=Decimal(0),
description=None,
extra_info={
"movement": "in",
"warehouse_id": wline.warehouse_id,
"inventory_tracked": True,
},
))
# لینک سند حسابداری به Warehouse Document
if not wh.extra_info:
wh.extra_info = {}
elif not isinstance(wh.extra_info, dict):
wh.extra_info = {}
wh.extra_info["accounting_document_id"] = accounting_doc.id
db.flush()
# شناسایی بهای تمام‌شده قطعی روی خطوط فاکتور و ثبت COGS در دفتر کل
# ورک‌فلو: موجودی کم (reorder_point)
try:
from app.services.workflow.workflow_trigger_service import maybe_fire_inventory_low_triggers
seen_pairs: set[tuple[int, Optional[int]]] = set()
for ln in lines:
if not ln.product_id:
continue
prod = db.query(Product).filter(Product.id == int(ln.product_id)).first()
if not prod or not getattr(prod, "track_inventory", False):
continue
wid = int(ln.warehouse_id) if ln.warehouse_id else None
key = (int(ln.product_id), wid)
if key in seen_pairs:
continue
seen_pairs.add(key)
maybe_fire_inventory_low_triggers(
db,
int(business_id),
int(ln.product_id),
wid,
user_id=getattr(wh, "created_by_user_id", None),
)
except Exception as inv_wf_err:
logger.warning(
"warehouse_workflow_inventory_low_failed wh_id=%s err=%s",
wh.id,
inv_wf_err,
exc_info=True,
)
# شناسایی بهای تمام‌شده قطعی روی خطوط فاکتور مبدأ (در صورت تنظیم کسب‌وکار)
try:
from app.services.invoice_profit_ledger_service import on_warehouse_document_posted
on_warehouse_document_posted(db, int(wh.id))
except Exception as ledger_hook_err:
logger.warning(
"warehouse_post ledger recognition hook failed wh_id=%s err=%s",
getattr(wh, "id", None),
ledger_hook_err,
exc_info=True,
)
try:
from app.services.purchase_accounting_service import post_purchase_grni_clearance_for_warehouse
post_purchase_grni_clearance_for_warehouse(db, int(wh.id))
except Exception as grni_err:
logger.warning(
"warehouse_post grni clearance failed wh_id=%s err=%s",
getattr(wh, "id", None),
grni_err,
exc_info=True,
)
try:
from app.services.woocommerce_stock_push_hook import (
collect_product_ids_from_warehouse_lines,
schedule_woocommerce_stock_push_after_warehouse_post,
)
schedule_woocommerce_stock_push_after_warehouse_post(
db,
business_id=int(business_id),
warehouse_document_id=int(wh.id),
product_ids=collect_product_ids_from_warehouse_lines(lines),
extra_info=wh.extra_info if isinstance(wh.extra_info, dict) else {},
)
except Exception as woo_push_err:
logger.warning(
"warehouse_post woocommerce stock push schedule failed wh_id=%s err=%s",
getattr(wh, "id", None),
woo_push_err,
exc_info=True,
)
return {"id": wh.id, "status": wh.status}
def _get_line_dict_with_instances(db: Session, ln: WarehouseDocumentLine, business_id: int) -> Dict[str, Any]:
"""تبدیل خط حواله به دیکشنری با اطلاعات کامل instance ها در صورت وجود"""
line_dict = {
"id": ln.id,
"product_id": ln.product_id,
"warehouse_id": ln.warehouse_id,
"warehouse_location_id": getattr(ln, "warehouse_location_id", None),
"movement": ln.movement,
"quantity": float(ln.quantity),
"extra_info": ln.extra_info,
"instance_ids": ln.instance_ids,
}
# اگر instance_ids وجود دارد، اطلاعات کامل instance ها را بارگذاری کن
if ln.instance_ids and isinstance(ln.instance_ids, list) and len(ln.instance_ids) > 0:
from adapters.db.models.product_instance import ProductInstance
instance_data = []
for inst_id in ln.instance_ids:
try:
inst_id_int = int(inst_id) if not isinstance(inst_id, int) else inst_id
# برای حواله‌های خروج، instance ها ممکن است status="sold" داشته باشند
# پس فیلتر بر اساس status نمی‌کنیم
instance = db.query(ProductInstance).filter(
and_(
ProductInstance.id == inst_id_int,
ProductInstance.business_id == business_id,
)
).first()
if instance:
instance_data.append({
"id": instance.id,
"serial_number": instance.serial_number,
"barcode": instance.barcode,
"custom_attributes": instance.custom_attributes or {},
})
except Exception:
# در صورت خطا، از instance_id استفاده کن
pass
if instance_data:
line_dict["instance_data"] = instance_data
return line_dict
def warehouse_document_to_dict(db: Session, wh: WarehouseDocument) -> Dict[str, Any]:
lines = db.query(WarehouseDocumentLine).filter(WarehouseDocumentLine.warehouse_document_id == wh.id).all()
doc_type_movement_map = {
"receipt": "in",
"production_in": "in",
"issue": "out",
"production_out": "out",
"transfer": "out",
}
movement_filter = doc_type_movement_map.get(wh.doc_type)
total_quantity = Decimal(0)
for ln in lines:
if movement_filter and (ln.movement or "").lower() != movement_filter:
continue
try:
qty = Decimal(str(ln.quantity or 0))
except Exception:
qty = Decimal(0)
if qty <= 0:
continue
total_quantity += qty
if total_quantity < 0:
total_quantity = Decimal(0)
# استخراج فیلدهای ارسال از extra_info
extra_info = wh.extra_info or {}
delivery_info = {
"description": extra_info.get("description") if isinstance(extra_info, dict) else None,
"delivery_method": extra_info.get("delivery_method") if isinstance(extra_info, dict) else None,
"carrier_name": extra_info.get("carrier_name") if isinstance(extra_info, dict) else None,
"recipient_name": extra_info.get("recipient_name") if isinstance(extra_info, dict) else None,
"recipient_phone": extra_info.get("recipient_phone") if isinstance(extra_info, dict) else None,
"tracking_number": extra_info.get("tracking_number") if isinstance(extra_info, dict) else None,
}
product_ids = {int(ln.product_id) for ln in lines if ln.product_id}
wh_ids: set[int] = set()
for ln in lines:
if ln.warehouse_id is not None:
wh_ids.add(int(ln.warehouse_id))
if wh.warehouse_id_from is not None:
wh_ids.add(int(wh.warehouse_id_from))
if wh.warehouse_id_to is not None:
wh_ids.add(int(wh.warehouse_id_to))
products_map: Dict[int, Product] = {}
if product_ids:
for p in db.query(Product).filter(
and_(Product.business_id == wh.business_id, Product.id.in_(product_ids))
).all():
products_map[int(p.id)] = p
category_ids = {int(p.category_id) for p in products_map.values() if getattr(p, "category_id", None)}
categories_map: Dict[int, BusinessCategory] = {}
if category_ids:
for c in db.query(BusinessCategory).filter(
and_(BusinessCategory.business_id == wh.business_id, BusinessCategory.id.in_(category_ids))
).all():
categories_map[int(c.id)] = c
warehouses_map: Dict[int, Warehouse] = {}
if wh_ids:
for w in db.query(Warehouse).filter(
and_(Warehouse.business_id == wh.business_id, Warehouse.id.in_(wh_ids))
).all():
warehouses_map[int(w.id)] = w
source_document_code: Optional[str] = None
source_invoice_document_type: Optional[str] = None
source_invoice_type_label_fa: Optional[str] = None
source_invoice_party_name: Optional[str] = None
st_wh = (wh.source_type or "").strip().lower()
if st_wh == "invoice" and wh.source_document_id:
src_doc = db.query(Document).filter(
and_(Document.id == int(wh.source_document_id), Document.business_id == wh.business_id)
).first()
if src_doc is not None:
source_document_code = src_doc.code
source_invoice_document_type = src_doc.document_type
dt_raw = src_doc.document_type or ""
source_invoice_type_label_fa = _INVOICE_TYPE_LABELS_FA.get(dt_raw) or (dt_raw or None)
extra_inv = src_doc.extra_info or {}
if isinstance(extra_inv, dict):
pn = extra_inv.get("person_name")
if pn and str(pn).strip():
source_invoice_party_name = str(pn).strip()
else:
pid = extra_inv.get("person_id")
if pid:
try:
per = db.query(Person).filter(
and_(Person.id == int(pid), Person.business_id == wh.business_id)
).first()
if per is not None:
source_invoice_party_name = _person_display_name_invoice_party(per) or None
except Exception:
pass
source_type_label_fa: Optional[str] = None
if wh.source_type:
source_type_label_fa = _SOURCE_TYPE_LABELS_FA.get(st_wh) or wh.source_type
fiscal_year_title: Optional[str] = None
if wh.fiscal_year_id:
fy = db.query(FiscalYear).filter(FiscalYear.id == int(wh.fiscal_year_id)).first()
if fy is not None:
fiscal_year_title = fy.title
created_by_name: Optional[str] = None
if wh.created_by_user_id:
u = db.query(User).filter(User.id == int(wh.created_by_user_id)).first()
if u is not None:
parts = [x for x in (u.first_name, u.last_name) if x]
created_by_name = " ".join(parts).strip() or (u.email or u.mobile or str(u.id))
def _warehouse_label(wid: Optional[int]) -> Optional[str]:
if wid is None:
return None
obj = warehouses_map.get(int(wid))
return obj.name if obj is not None else None
accounting_document_id = extra_info.get("accounting_document_id") if isinstance(extra_info, dict) else None
def _category_display_title(cat: Optional[BusinessCategory]) -> Optional[str]:
if cat is None:
return None
trans = cat.title_translations or {}
if isinstance(trans, dict):
return trans.get("fa") or trans.get("en") or trans.get("default") or None
return None
line_out: List[Dict[str, Any]] = []
for ln in lines:
d = _get_line_dict_with_instances(db, ln, wh.business_id)
p = products_map.get(int(ln.product_id)) if ln.product_id else None
if p is not None:
d["product_name"] = p.name
d["product_code"] = p.code
d["product_main_unit"] = p.main_unit
if p.category_id:
cat = categories_map.get(int(p.category_id))
cat_title = _category_display_title(cat)
if cat_title:
d["product_category_name"] = cat_title
if ln.warehouse_id is not None:
wobj = warehouses_map.get(int(ln.warehouse_id))
if wobj is not None:
d["warehouse_name"] = wobj.name
line_out.append(d)
return {
"id": wh.id,
"code": wh.code,
"business_id": wh.business_id,
"fiscal_year_id": wh.fiscal_year_id,
"fiscal_year_title": fiscal_year_title,
"document_date": wh.document_date.isoformat() if wh.document_date else None,
"status": wh.status,
"doc_type": wh.doc_type,
"warehouse_id_from": wh.warehouse_id_from,
"warehouse_id_to": wh.warehouse_id_to,
"warehouse_name_from": _warehouse_label(wh.warehouse_id_from),
"warehouse_name_to": _warehouse_label(wh.warehouse_id_to),
"source_type": wh.source_type,
"source_type_label_fa": source_type_label_fa,
"source_document_id": wh.source_document_id,
"source_document_code": source_document_code,
"source_invoice_document_type": source_invoice_document_type,
"source_invoice_type_label_fa": source_invoice_type_label_fa,
"source_invoice_party_name": source_invoice_party_name,
"extra_info": wh.extra_info,
"description": delivery_info["description"],
"delivery_method": delivery_info["delivery_method"],
"carrier_name": delivery_info["carrier_name"],
"recipient_name": delivery_info["recipient_name"],
"recipient_phone": delivery_info["recipient_phone"],
"tracking_number": delivery_info["tracking_number"],
"total_quantity": float(total_quantity),
"created_at": wh.created_at.isoformat() if wh.created_at else None,
"updated_at": wh.updated_at.isoformat() if wh.updated_at else None,
"created_by_user_id": wh.created_by_user_id,
"created_by_name": created_by_name,
"accounting_document_id": accounting_document_id,
"lines": line_out,
}
def _to_dict(obj: Warehouse) -> Dict[str, Any]:
return {
"id": obj.id,
"business_id": obj.business_id,
"code": obj.code,
"name": obj.name,
"description": obj.description,
"warehouse_keeper": obj.warehouse_keeper,
"phone": obj.phone,
"address": obj.address,
"postal_code": obj.postal_code,
"is_default": obj.is_default,
"created_at": obj.created_at,
"updated_at": obj.updated_at,
}
def invalidate_warehouses_cache(business_id: int):
"""
Invalidate cache برای لیست انبارها
"""
from app.core.cache import get_cache
cache = get_cache()
if cache.enabled:
try:
deleted_count = cache.invalidate_warehouses_by_business(business_id)
logger.info(f"Invalidated {deleted_count} cache keys for warehouses, business_id {business_id}")
except Exception as e:
logger.warning(f"Error invalidating warehouses cache for business_id {business_id}: {e}")
def invalidate_warehouse_docs_cache(business_id: int, fiscal_year_id: Optional[int] = None, doc_type: Optional[str] = None, warehouse_id: Optional[int] = None, status: Optional[str] = None, document_id: Optional[int] = None):
"""
Invalidate cache برای اسناد انبار
"""
from app.core.cache import get_cache
cache = get_cache()
if cache.enabled:
try:
deleted_count = cache.invalidate_warehouse_docs_by_business(
business_id=business_id,
fiscal_year_id=fiscal_year_id,
doc_type=doc_type,
warehouse_id=warehouse_id,
status=status,
document_id=document_id
)
logger.info(f"Invalidated {deleted_count} cache keys for warehouse_docs, business_id {business_id}, fiscal_year_id {fiscal_year_id}, doc_type {doc_type}, warehouse_id {warehouse_id}, status {status}, document_id {document_id}")
except Exception as e:
logger.warning(f"Error invalidating warehouse_docs cache for business_id {business_id}: {e}")
def create_warehouse(db: Session, business_id: int, payload: WarehouseCreateRequest) -> Dict[str, Any]:
# تولید خودکار کد در صورت عدم ارسال
if payload.code is None or not payload.code.strip():
code = _generate_auto_warehouse_code(db, business_id)
else:
code = payload.code.strip()
# بررسی تکراری بودن کد
dup = db.query(Warehouse).filter(and_(Warehouse.business_id == business_id, Warehouse.code == code)).first()
if dup:
raise ApiError("DUPLICATE_WAREHOUSE_CODE", "کد انبار تکراری است", http_status=400)
repo = WarehouseRepository(db)
obj = repo.create(
business_id=business_id,
code=code,
name=payload.name.strip(),
description=payload.description,
warehouse_keeper=payload.warehouse_keeper.strip() if payload.warehouse_keeper else None,
phone=payload.phone.strip() if payload.phone else None,
address=payload.address.strip() if payload.address else None,
postal_code=payload.postal_code.strip() if payload.postal_code else None,
is_default=bool(payload.is_default),
)
if obj.is_default:
db.query(Warehouse).filter(and_(Warehouse.business_id == business_id, Warehouse.id != obj.id)).update({Warehouse.is_default: False})
db.commit()
# Invalidate cache بعد از ایجاد موفق انبار
invalidate_warehouses_cache(business_id)
return {"message": "WAREHOUSE_CREATED", "data": _to_dict(obj)}
def list_warehouses(db: Session, business_id: int) -> Dict[str, Any]:
repo = WarehouseRepository(db)
rows = repo.list(business_id)
return {"items": [_to_dict(w) for w in rows]}
def get_warehouse(db: Session, business_id: int, warehouse_id: int) -> Optional[Dict[str, Any]]:
obj = db.get(Warehouse, warehouse_id)
if not obj or obj.business_id != business_id:
return None
return _to_dict(obj)
def update_warehouse(db: Session, business_id: int, warehouse_id: int, payload: WarehouseUpdateRequest) -> Optional[Dict[str, Any]]:
repo = WarehouseRepository(db)
obj = db.get(Warehouse, warehouse_id)
if not obj or obj.business_id != business_id:
return None
if payload.code and payload.code.strip() != obj.code:
dup = db.query(Warehouse).filter(and_(Warehouse.business_id == business_id, Warehouse.code == payload.code.strip(), Warehouse.id != warehouse_id)).first()
if dup:
raise ApiError("DUPLICATE_WAREHOUSE_CODE", "کد انبار تکراری است", http_status=400)
updated = repo.update(
warehouse_id,
code=payload.code.strip() if isinstance(payload.code, str) and payload.code.strip() else None,
name=payload.name.strip() if isinstance(payload.name, str) else None,
description=payload.description,
warehouse_keeper=payload.warehouse_keeper.strip() if isinstance(payload.warehouse_keeper, str) and payload.warehouse_keeper.strip() else None,
phone=payload.phone.strip() if isinstance(payload.phone, str) and payload.phone.strip() else None,
address=payload.address.strip() if isinstance(payload.address, str) and payload.address.strip() else None,
postal_code=payload.postal_code.strip() if isinstance(payload.postal_code, str) and payload.postal_code.strip() else None,
is_default=payload.is_default if payload.is_default is not None else None,
)
if not updated:
return None
if updated.is_default:
db.query(Warehouse).filter(and_(Warehouse.business_id == business_id, Warehouse.id != updated.id)).update({Warehouse.is_default: False})
db.commit()
# Invalidate cache بعد از به‌روزرسانی موفق انبار
invalidate_warehouses_cache(business_id)
return {"message": "WAREHOUSE_UPDATED", "data": _to_dict(updated)}
def delete_warehouse(db: Session, business_id: int, warehouse_id: int) -> bool:
obj = db.get(Warehouse, warehouse_id)
if not obj or obj.business_id != business_id:
return False
# جلوگیری از حذف انباری که در هدر یا خطوط حواله انبار استفاده شده است
has_doc = (
db.query(WarehouseDocument.id)
.filter(
WarehouseDocument.business_id == business_id,
or_(
WarehouseDocument.warehouse_id_from == warehouse_id,
WarehouseDocument.warehouse_id_to == warehouse_id,
),
)
.first()
)
if has_doc is not None:
raise ApiError(
"WAREHOUSE_HAS_WAREHOUSE_DOCUMENTS",
"به‌دلیل وجود حواله انبار مرتبط با این انبار، حذف امکان‌پذیر نیست؛ ابتدا حواله‌ها را اصلاح یا حذف کنید.",
http_status=400,
)
has_line = (
db.query(WarehouseDocumentLine.id)
.join(WarehouseDocument, WarehouseDocument.id == WarehouseDocumentLine.warehouse_document_id)
.filter(
WarehouseDocument.business_id == business_id,
WarehouseDocumentLine.warehouse_id == warehouse_id,
)
.first()
)
if has_line is not None:
raise ApiError(
"WAREHOUSE_HAS_LINE_MOVEMENTS",
"به‌دلیل وجود حواله انبار با ردیف‌های مرتبط با این انبار، حذف امکان‌پذیر نیست.",
http_status=400,
)
repo = WarehouseRepository(db)
try:
result = repo.delete(warehouse_id)
except IntegrityError:
db.rollback()
raise ApiError(
"WAREHOUSE_DELETE_CONFLICT",
"حذف انبار به‌دلیل وابستگی‌های دیگر در سیستم امکان‌پذیر نیست.",
http_status=400,
) from None
# Invalidate cache بعد از حذف موفق انبار
if result:
invalidate_warehouses_cache(business_id)
# همچنین اسناد انبار مرتبط با این انبار را هم invalidate کن
from app.core.cache import get_cache
cache = get_cache()
if cache.enabled:
try:
cache.invalidate_warehouse_docs_by_business(
business_id=business_id,
warehouse_id=warehouse_id
)
except Exception as e:
logger.warning(f"Error invalidating warehouse_docs cache for warehouse_id {warehouse_id}: {e}")
return result
def query_warehouses(db: Session, business_id: int, query_info: QueryInfo) -> Dict[str, Any]:
# Ensure business scoping via filters
base_filter = FilterItem(property="business_id", operator="=", value=business_id)
merged_filters = [base_filter]
if query_info.filters:
merged_filters.extend(query_info.filters)
effective_query = QueryInfo(
sort_by=query_info.sort_by,
sort_desc=query_info.sort_desc,
take=query_info.take,
skip=query_info.skip,
search=query_info.search,
search_fields=query_info.search_fields,
filters=merged_filters,
)
results, total = QueryService.query_with_filters(Warehouse, db, effective_query)
items = [_to_dict(w) for w in results]
limit = max(1, effective_query.take)
page = (effective_query.skip // limit) + 1
total_pages = (total + limit - 1) // limit
return {
"items": items,
"total": total,
"page": page,
"limit": limit,
"total_pages": total_pages,
}
def delete_warehouse_document(db: Session, business_id: int, wh_id: int) -> bool:
"""حذف حواله انبار (فقط draft)."""
wh = db.query(WarehouseDocument).filter(WarehouseDocument.id == wh_id).first()
if not wh or wh.business_id != business_id:
raise ApiError("NOT_FOUND", "Warehouse document not found", http_status=404)
if wh.status != "draft":
raise ApiError("NOT_DELETABLE", "فقط حواله‌های draft قابل حذف هستند", http_status=400)
# دریافت اطلاعات قبل از حذف برای invalidation
fiscal_year_id = wh.fiscal_year_id
doc_type = wh.doc_type
warehouse_id_from = wh.warehouse_id_from
warehouse_id_to = wh.warehouse_id_to
status = wh.status
db.delete(wh)
db.flush()
# Invalidate cache بعد از حذف موفق سند انبار
invalidate_warehouse_docs_cache(
business_id=business_id,
fiscal_year_id=fiscal_year_id,
doc_type=doc_type,
warehouse_id=warehouse_id_from or warehouse_id_to,
status=status,
document_id=wh_id
)
# اگر transfer باشد، هر دو انبار را invalidate کن
if doc_type == "transfer" and warehouse_id_from and warehouse_id_to:
invalidate_warehouse_docs_cache(
business_id=business_id,
fiscal_year_id=fiscal_year_id,
doc_type=doc_type,
warehouse_id=warehouse_id_to,
status=status
)
return True
def bulk_delete_warehouse_documents(db: Session, business_id: int, doc_ids: List[int]) -> Dict[str, Any]:
"""حذف گروهی حواله‌های انبار؛ فقط حواله‌های draft و متعلق به همان کسب‌وکار حذف می‌شوند."""
if not doc_ids:
return {"deleted_count": 0, "total_requested": 0, "skipped": [], "errors": []}
deleted_count = 0
skipped: List[Dict[str, Any]] = []
errors: List[Dict[str, Any]] = []
for doc_id in doc_ids:
try:
wh = db.query(WarehouseDocument).filter(WarehouseDocument.id == doc_id).first()
if not wh:
errors.append({"id": doc_id, "reason": "NOT_FOUND"})
continue
if wh.business_id != business_id:
errors.append({"id": doc_id, "reason": "BUSINESS_MISMATCH"})
continue
if wh.status != "draft":
skipped.append({"id": doc_id, "code": wh.code, "status": wh.status})
continue
db.delete(wh)
deleted_count += 1
except Exception as exc:
errors.append({"id": doc_id, "reason": str(exc)})
db.flush()
return {
"deleted_count": deleted_count,
"total_requested": len(doc_ids),
"skipped": skipped,
"errors": errors,
}
def cancel_warehouse_document(
db: Session,
business_id: int,
wh_id: int,
user_id: int,
*,
seal_reversal: Optional[bool] = None,
) -> WarehouseDocument:
"""لغو حواله posted با ایجاد حواله معکوس.
برای حواله‌هایی که موجودی از خطوط حواله شمرده می‌شود (دستی، کالای هزینه/درآمد، …)
حوالهٔ معکوس به‌صورت cancelled/بایگانی پلمپ می‌شود تا با پست مجدد موجودی دوبار اصلاح نشود.
برای حوالهٔ فاکتور، پیش‌فرض draft می‌ماند تا جریان لغو فاکتور بتواند آن را پست کند.
"""
wh = db.query(WarehouseDocument).filter(WarehouseDocument.id == wh_id).first()
if not wh or wh.business_id != business_id:
raise ApiError("NOT_FOUND", "Warehouse document not found", http_status=404)
if wh.status != "posted":
raise ApiError("NOT_CANCELLABLE", "فقط حواله‌های posted قابل لغو هستند", http_status=400)
# ایجاد حواله معکوس
lines = db.query(WarehouseDocumentLine).filter(WarehouseDocumentLine.warehouse_document_id == wh.id).all()
if not lines:
raise ApiError("NO_LINES", "حواله خطی ندارد", http_status=400)
from app.services.warehouse_placement_sync import reverse_placement_effects_for_cancelled_document
reverse_placement_effects_for_cancelled_document(db, business_id, lines)
fy = _get_current_fiscal_year(db, business_id)
# تعیین نوع حواله معکوس
reverse_doc_type = wh.doc_type
if wh.doc_type == "receipt":
reverse_doc_type = "issue"
elif wh.doc_type == "issue":
reverse_doc_type = "receipt"
# برای transfer و adjustment همان نوع باقی می‌ماند
do_seal = (
should_seal_warehouse_cancel_reversal(db, business_id, wh)
if seal_reversal is None
else bool(seal_reversal)
)
reverse_status = "cancelled" if do_seal else "draft"
reverse_extra: Dict[str, Any] = {
"cancels_warehouse_document_id": wh.id,
"cancellation_reason": "لغو حواله",
"cancelled_source_type": (wh.source_type or None),
}
if do_seal:
reverse_extra["audit_only_reversal"] = True
reverse_extra["stock_already_corrected_by_cancel"] = True
cancel_wh: Optional[WarehouseDocument] = None
for attempt in range(10):
code = _generate_warehouse_document_code(db, business_id, wh.document_date)
try:
with db.begin_nested():
cancel_wh = WarehouseDocument(
business_id=business_id,
fiscal_year_id=fy.id,
code=code,
document_date=wh.document_date,
status=reverse_status,
doc_type=reverse_doc_type,
warehouse_id_from=wh.warehouse_id_to, # معکوس
warehouse_id_to=wh.warehouse_id_from, # معکوس
source_type="manual",
source_document_id=wh.id, # لینک به حواله اصلی
created_by_user_id=user_id,
extra_info=reverse_extra,
)
db.add(cancel_wh)
db.flush()
break
except IntegrityError as e:
if _is_duplicate_warehouse_document_code_error(e) and attempt < 9:
continue
raise
if cancel_wh is None:
raise ApiError("WAREHOUSE_CODE_CONFLICT", "Failed to generate unique warehouse document code", http_status=500)
# ایجاد خطوط معکوس
for ln in lines:
reverse_movement = "in" if ln.movement == "out" else "out"
reverse_wh = ln.warehouse_id # برای transfer باید معکوس شود
# برای transfer، انبار باید معکوس شود
if wh.doc_type == "transfer":
# پیدا کردن خط جفت (out/in) برای تعیین انبار معکوس
if ln.movement == "out":
reverse_wh = wh.warehouse_id_to
else:
reverse_wh = wh.warehouse_id_from
cancel_line = WarehouseDocumentLine(
warehouse_document_id=cancel_wh.id,
product_id=ln.product_id,
warehouse_id=reverse_wh,
movement=reverse_movement,
quantity=ln.quantity,
extra_info={
"cancels_line_id": ln.id,
**(ln.extra_info or {}),
},
)
db.add(cancel_line)
try:
from app.services.purchase_accounting_service import reverse_purchase_grni_clearance_for_warehouse
reverse_purchase_grni_clearance_for_warehouse(db, int(wh.id))
except Exception as grni_rev_err:
logger.warning(
"warehouse_cancel grni reversal failed wh_id=%s err=%s",
wh.id,
grni_rev_err,
exc_info=True,
)
# دریافت اطلاعات قبل از تغییر status برای invalidation
old_status = wh.status
business_id = wh.business_id
fiscal_year_id = wh.fiscal_year_id
doc_type = wh.doc_type
warehouse_id_from = wh.warehouse_id_from
warehouse_id_to = wh.warehouse_id_to
# تغییر وضعیت حواله اصلی به cancelled
wh.status = "cancelled"
wh.touch()
try:
from app.services.invoice_cogs_gl_service import on_warehouse_invoice_cogs_gl_cancel_sync
on_warehouse_invoice_cogs_gl_cancel_sync(
db,
source_type=getattr(wh, "source_type", None),
source_document_id=getattr(wh, "source_document_id", None),
)
except Exception as cogs_cancel_err:
logger.warning(
"invoice_cogs_gl sync after warehouse cancel failed wh_id=%s err=%s",
wh.id,
cogs_cancel_err,
exc_info=True,
)
db.flush()
# Invalidate cache بعد از تغییر status به cancelled و ایجاد حواله معکوس
invalidate_warehouse_docs_cache(
business_id=business_id,
fiscal_year_id=fiscal_year_id,
doc_type=doc_type,
warehouse_id=warehouse_id_from or warehouse_id_to,
status=old_status,
document_id=wh.id
)
invalidate_warehouse_docs_cache(
business_id=business_id,
fiscal_year_id=fiscal_year_id,
doc_type=doc_type,
warehouse_id=warehouse_id_from or warehouse_id_to,
status="cancelled",
document_id=wh.id
)
# برای حواله معکوس جدید
invalidate_warehouse_docs_cache(
business_id=business_id,
fiscal_year_id=cancel_wh.fiscal_year_id,
doc_type=cancel_wh.doc_type,
warehouse_id=cancel_wh.warehouse_id_from or cancel_wh.warehouse_id_to,
status=cancel_wh.status,
document_id=cancel_wh.id
)
# اگر transfer باشد، هر دو انبار را invalidate کن
if doc_type == "transfer" and warehouse_id_from and warehouse_id_to:
invalidate_warehouse_docs_cache(
business_id=business_id,
fiscal_year_id=fiscal_year_id,
doc_type=doc_type,
warehouse_id=warehouse_id_to,
status=old_status
)
invalidate_warehouse_docs_cache(
business_id=business_id,
fiscal_year_id=fiscal_year_id,
doc_type=doc_type,
warehouse_id=warehouse_id_to,
status="cancelled"
)
if cancel_wh.doc_type == "transfer" and cancel_wh.warehouse_id_from and cancel_wh.warehouse_id_to:
invalidate_warehouse_docs_cache(
business_id=business_id,
fiscal_year_id=cancel_wh.fiscal_year_id,
doc_type=cancel_wh.doc_type,
warehouse_id=cancel_wh.warehouse_id_to,
status=cancel_wh.status
)
return cancel_wh
def get_warehouse_stock_report(
db: Session,
business_id: int,
query: Dict[str, Any],
) -> Dict[str, Any]:
"""گزارش موجودی انبار به تفکیک محصول و انبار."""
from datetime import date as date_type
# پارامترهای ورودی
product_ids = query.get("product_ids", [])
warehouse_ids = query.get("warehouse_ids", [])
as_of_date_str = query.get("as_of_date")
include_zero = bool(query.get("include_zero", False))
# تبدیل تاریخ
as_of_date = datetime.now().date()
if as_of_date_str:
try:
as_of_date = date_type.fromisoformat(as_of_date_str) if isinstance(as_of_date_str, str) else as_of_date_str
except Exception:
pass
# دریافت لیست محصولات
if product_ids:
products = db.query(Product).filter(
and_(
Product.business_id == business_id,
Product.id.in_([int(p) for p in product_ids]),
Product.track_inventory == True,
)
).all()
else:
products = db.query(Product).filter(
and_(
Product.business_id == business_id,
Product.track_inventory == True,
)
).all()
# دریافت لیست انبارها
if warehouse_ids:
warehouses = db.query(Warehouse).filter(
and_(
Warehouse.business_id == business_id,
Warehouse.id.in_([int(w) for w in warehouse_ids]),
)
).all()
else:
warehouses = db.query(Warehouse).filter(Warehouse.business_id == business_id).all()
product_id_list = [p.id for p in products]
products_with_wh_history, wh_history_pairs = get_warehouse_history_index(
db,
business_id,
product_ids=product_id_list or None,
warehouse_ids=[int(w) for w in warehouse_ids] if warehouse_ids else None,
)
# اگر انباری وجود ندارد، یک رکورد "بدون انبار" اضافه کن
items = []
for product in products:
if warehouse_ids:
# فقط انبارهای انتخاب شده
wh_list = [w for w in warehouses if w.id in [int(wid) for wid in warehouse_ids]]
else:
wh_list = warehouses
# اگر انباری انتخاب نشده، موجودی کل را محاسبه کن
if not wh_list:
stock = get_physical_stock(db, business_id, product.id, None, as_of_date)
if _include_inventory_stock_row(
stock=stock,
include_zero=include_zero,
has_warehouse_history=product.id in products_with_wh_history,
):
items.append({
"product_id": product.id,
"product_code": product.code,
"product_name": product.name,
"warehouse_id": None,
"warehouse_code": None,
"warehouse_name": "بدون انبار / کل",
"quantity": float(stock),
"unit": product.main_unit or "",
})
else:
# موجودی به تفکیک انبار
for warehouse in wh_list:
stock = get_physical_stock(db, business_id, product.id, warehouse.id, as_of_date)
if _include_inventory_stock_row(
stock=stock,
include_zero=include_zero,
has_warehouse_history=(product.id, warehouse.id) in wh_history_pairs,
):
items.append({
"product_id": product.id,
"product_code": product.code,
"product_name": product.name,
"warehouse_id": warehouse.id,
"warehouse_code": warehouse.code,
"warehouse_name": warehouse.name,
"quantity": float(stock),
"unit": product.main_unit or "",
})
return {
"items": items,
"as_of_date": as_of_date.isoformat(),
"total_items": len(items),
}
def start_stock_count(
db: Session,
business_id: int,
warehouse_id: Optional[int] = None,
product_ids: Optional[List[int]] = None,
as_of_date: Optional[date] = None,
only_with_warehouse_history: bool = False,
) -> Dict[str, Any]:
"""شروع انبار گردانی: دریافت لیست محصولات با موجودی سیستم (فقط حواله‌های posted)."""
if as_of_date is None:
as_of_date = datetime.now().date()
products_query = db.query(Product).filter(
and_(
Product.business_id == business_id,
Product.track_inventory == True,
)
)
if product_ids:
products_query = products_query.filter(Product.id.in_([int(p) for p in product_ids]))
products = products_query.all()
product_id_list = [p.id for p in products]
if warehouse_id:
warehouses = db.query(Warehouse).filter(
and_(
Warehouse.business_id == business_id,
Warehouse.id == warehouse_id,
)
).all()
else:
warehouses = db.query(Warehouse).filter(Warehouse.business_id == business_id).all()
products_with_wh_history: set[int] = set()
wh_history_pairs: set[tuple[int, int]] = set()
if only_with_warehouse_history and product_id_list:
products_with_wh_history, wh_history_pairs = get_warehouse_history_index(
db,
business_id,
product_ids=product_id_list,
warehouse_ids=[int(warehouse_id)] if warehouse_id else None,
)
selected_wh = warehouses[0] if warehouse_id and warehouses else None
items = []
for product in products:
if warehouse_id:
if only_with_warehouse_history and (product.id, int(warehouse_id)) not in wh_history_pairs:
continue
stock = get_physical_stock(db, business_id, product.id, warehouse_id, as_of_date)
items.append({
"product_id": product.id,
"product_code": product.code or "",
"product_name": product.name,
"warehouse_id": warehouse_id,
"warehouse_code": selected_wh.code if selected_wh else None,
"warehouse_name": selected_wh.name if selected_wh else None,
"system_quantity": float(stock),
"unit": product.main_unit or "",
})
else:
if warehouses:
for warehouse in warehouses:
if only_with_warehouse_history and (product.id, warehouse.id) not in wh_history_pairs:
continue
stock = get_physical_stock(db, business_id, product.id, warehouse.id, as_of_date)
items.append({
"product_id": product.id,
"product_code": product.code or "",
"product_name": product.name,
"warehouse_id": warehouse.id,
"warehouse_code": warehouse.code,
"warehouse_name": warehouse.name,
"system_quantity": float(stock),
"unit": product.main_unit or "",
})
else:
if only_with_warehouse_history and product.id not in products_with_wh_history:
continue
stock = get_physical_stock(db, business_id, product.id, None, as_of_date)
items.append({
"product_id": product.id,
"product_code": product.code or "",
"product_name": product.name,
"warehouse_id": None,
"warehouse_code": None,
"warehouse_name": "بدون انبار / کل",
"system_quantity": float(stock),
"unit": product.main_unit or "",
})
return {
"items": items,
"as_of_date": as_of_date.isoformat(),
"total_items": len(items),
"only_with_warehouse_history": bool(only_with_warehouse_history),
}
def calculate_stock_count_differences(
db: Session,
business_id: int,
items: List[Dict[str, Any]],
as_of_date: Optional[date] = None,
) -> Dict[str, Any]:
"""محاسبه تفاوت‌های انبار گردانی؛ موجودی سیستم از سرور (posted) خوانده می‌شود."""
if as_of_date is None:
as_of_date = datetime.now().date()
result_items = []
for item in items:
product_id = item.get("product_id")
warehouse_id = item.get("warehouse_id")
if not product_id:
continue
try:
wh_id = int(warehouse_id) if warehouse_id is not None else None
except (TypeError, ValueError):
wh_id = None
system_quantity = get_physical_stock(
db,
business_id,
int(product_id),
wh_id,
as_of_date,
)
try:
physical_quantity = Decimal(str(item.get("physical_quantity", 0)))
except Exception:
physical_quantity = Decimal(0)
difference = physical_quantity - system_quantity
# تعیین نوع حرکت برای حواله تعدیل
movement = None
if difference > 0:
movement = "in" # افزایش موجودی
elif difference < 0:
movement = "out" # کاهش موجودی
result_items.append({
"product_id": int(product_id),
"warehouse_id": int(warehouse_id) if warehouse_id else None,
"system_quantity": float(system_quantity),
"physical_quantity": float(physical_quantity),
"difference": float(difference),
"movement": movement,
"quantity": float(abs(difference)) if difference != 0 else 0.0,
})
# محاسبه خلاصه
total_items = len(result_items)
items_with_difference = len([i for i in result_items if i["difference"] != 0])
items_increased = len([i for i in result_items if i["difference"] > 0])
items_decreased = len([i for i in result_items if i["difference"] < 0])
return {
"items": result_items,
"summary": {
"total_items": total_items,
"items_with_difference": items_with_difference,
"items_increased": items_increased,
"items_decreased": items_decreased,
},
}
def create_stock_count_adjustment(
db: Session,
business_id: int,
user_id: Optional[int],
stock_count_code: str,
stock_count_date: date,
items: List[Dict[str, Any]],
notes: Optional[str] = None,
) -> WarehouseDocument:
"""ایجاد حواله تعدیل از تفاوت‌های انبار گردانی."""
fy = _get_current_fiscal_year(db, business_id)
# فیلتر کردن فقط آیتم‌هایی که تفاوت دارند
adjustment_items = [item for item in items if item.get("difference", 0) != 0]
if not adjustment_items:
raise ApiError("NO_DIFFERENCES", "هیچ تفاوتی برای ایجاد حواله تعدیل وجود ندارد", http_status=400)
# ایجاد حواله تعدیل
wh: Optional[WarehouseDocument] = None
for attempt in range(10):
code = _generate_warehouse_document_code(db, business_id, stock_count_date)
try:
with db.begin_nested():
wh = WarehouseDocument(
business_id=business_id,
fiscal_year_id=fy.id,
code=code,
document_date=stock_count_date,
status="draft",
doc_type="adjustment",
warehouse_id_from=None,
warehouse_id_to=None,
source_type="manual",
source_document_id=None,
created_by_user_id=user_id,
extra_info={
"stock_count_code": stock_count_code,
"stock_count_date": stock_count_date.isoformat(),
"notes": notes,
},
)
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", "Failed to generate unique warehouse document code", http_status=500)
# ایجاد خطوط حواله
for item in adjustment_items:
product_id = item.get("product_id")
warehouse_id = item.get("warehouse_id")
movement = item.get("movement")
if not product_id or not movement:
continue
quantity = _parse_stock_count_adjustment_quantity(item.get("quantity", 0), str(product_id))
if quantity is None:
continue
# بررسی محصول
product = db.query(Product).filter(
and_(Product.id == int(product_id), Product.business_id == business_id)
).first()
if not product:
continue
line = WarehouseDocumentLine(
warehouse_document_id=wh.id,
product_id=int(product_id),
warehouse_id=int(warehouse_id) if warehouse_id else None,
movement=movement,
quantity=quantity,
extra_info={
"system_quantity": item.get("system_quantity"),
"physical_quantity": item.get("physical_quantity"),
"difference": item.get("difference"),
},
)
db.add(line)
db.flush()
return wh
def get_physical_stock(
db: Session,
business_id: int,
product_id: int,
warehouse_id: Optional[int] = None,
as_of_date: Optional[date] = None,
) -> Decimal:
"""موجودی انبارداری (فیزیکی) یک کالا؛ فقط حواله‌های posted."""
stocks = get_physical_stock_bulk(
db,
business_id,
[int(product_id)],
as_of_date=as_of_date,
warehouse_id=warehouse_id,
)
return stocks.get(int(product_id), Decimal(0))
def get_physical_stock_bulk(
db: Session,
business_id: int,
product_ids: List[int],
as_of_date: Optional[date] = None,
warehouse_id: Optional[int] = None,
) -> Dict[int, Decimal]:
"""
محاسبه موجودی انبارداری (فیزیکی) برای لیستی از کالاها.
بر اساس حواله‌های انبار با وضعیت posted.
بازگشت: Dict[product_id, quantity]
"""
if not product_ids:
return {}
if as_of_date is None:
as_of_date = datetime.now().date()
lines_query = (
db.query(WarehouseDocumentLine, WarehouseDocument)
.join(WarehouseDocument, WarehouseDocument.id == WarehouseDocumentLine.warehouse_document_id)
.filter(
and_(
WarehouseDocument.business_id == business_id,
WarehouseDocument.status == "posted",
WarehouseDocument.document_date <= as_of_date,
WarehouseDocumentLine.product_id.in_(product_ids),
)
)
)
if warehouse_id is not None:
lines_query = lines_query.filter(WarehouseDocumentLine.warehouse_id == int(warehouse_id))
lines = lines_query.all()
stock_dict: Dict[int, Decimal] = {}
for line, _doc in lines:
pid = int(line.product_id)
qty = Decimal(str(line.quantity or 0))
if qty <= 0:
continue
if pid not in stock_dict:
stock_dict[pid] = Decimal(0)
if line.movement == "in":
stock_dict[pid] += qty
elif line.movement == "out":
stock_dict[pid] -= qty
for pid in product_ids:
if pid not in stock_dict:
stock_dict[pid] = Decimal(0)
return stock_dict
def get_warehouse_history_index(
db: Session,
business_id: int,
product_ids: Optional[List[int]] = None,
warehouse_ids: Optional[List[int]] = None,
) -> tuple[set[int], set[tuple[int, int]]]:
"""
کالاهایی که حداقل یک خط حواله انبار (هر وضعیتی) دارند.
بازگشت: (product_ids, {(product_id, warehouse_id), ...})
"""
q = (
db.query(WarehouseDocumentLine.product_id, WarehouseDocumentLine.warehouse_id)
.join(
WarehouseDocument,
WarehouseDocument.id == WarehouseDocumentLine.warehouse_document_id,
)
.filter(WarehouseDocument.business_id == int(business_id))
.distinct()
)
if product_ids:
q = q.filter(WarehouseDocumentLine.product_id.in_([int(p) for p in product_ids]))
if warehouse_ids:
q = q.filter(WarehouseDocumentLine.warehouse_id.in_([int(w) for w in warehouse_ids]))
product_set: set[int] = set()
pair_set: set[tuple[int, int]] = set()
for pid, wh_id in q.all():
if pid is None:
continue
product_set.add(int(pid))
if wh_id is not None:
pair_set.add((int(pid), int(wh_id)))
return product_set, pair_set
def _include_inventory_stock_row(
*,
stock: Decimal,
include_zero: bool,
has_warehouse_history: bool,
) -> bool:
if include_zero or stock != 0:
return True
return has_warehouse_history