forked from hesabix/arc
4273 lines
160 KiB
Python
Executable file
4273 lines
160 KiB
Python
Executable file
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
|
||
|