arc/hesabixAPI/app/services/warehouse_reports_service.py

1143 lines
39 KiB
Python
Executable file

"""
Service functions for warehouse reports
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from decimal import Decimal
from datetime import datetime, date, timedelta
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, func, case
from sqlalchemy.orm import aliased
from adapters.db.models.warehouse_document import WarehouseDocument
from adapters.db.models.warehouse_document_line import WarehouseDocumentLine
from adapters.db.models.product import Product
from adapters.db.models.warehouse import Warehouse
from adapters.db.models.business import Business
from adapters.db.models.currency import Currency
from app.core.responses import ApiError
from app.services.invoice_service import _compute_available_stock
def get_warehouse_documents_summary_report(
db: Session,
business_id: int,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
doc_types: Optional[List[str]] = None,
warehouse_ids: Optional[List[int]] = None,
status: Optional[str] = None,
skip: int = 0,
take: int = 50,
) -> Dict[str, Any]:
"""
گزارش خلاصه حواله‌های انبار
"""
from app.services.transfer_service import _parse_iso_date as _parse_date
query = db.query(WarehouseDocument).filter(
WarehouseDocument.business_id == business_id
)
# فیلتر تاریخ
if date_from:
try:
query = query.filter(WarehouseDocument.document_date >= _parse_date(date_from))
except Exception:
pass
if date_to:
try:
query = query.filter(WarehouseDocument.document_date <= _parse_date(date_to))
except Exception:
pass
# فیلتر نوع حواله
if doc_types:
query = query.filter(WarehouseDocument.doc_type.in_(doc_types))
# فیلتر انبار
if warehouse_ids:
query = query.filter(
or_(
WarehouseDocument.warehouse_id_from.in_(warehouse_ids),
WarehouseDocument.warehouse_id_to.in_(warehouse_ids),
)
)
# فیلتر وضعیت
if status:
query = query.filter(WarehouseDocument.status == status)
# فقط حواله‌های posted
query = query.filter(WarehouseDocument.status == "posted")
total = query.count()
documents = query.order_by(
WarehouseDocument.document_date.desc(),
WarehouseDocument.id.desc()
).offset(skip).limit(take).all()
# محاسبه آمار
summary_by_type = {}
total_documents = 0
total_items = 0
total_quantity_in = Decimal(0)
total_quantity_out = Decimal(0)
for doc in documents:
doc_type = doc.doc_type
if doc_type not in summary_by_type:
summary_by_type[doc_type] = {
"count": 0,
"items_count": 0,
"quantity_in": Decimal(0),
"quantity_out": Decimal(0),
}
summary_by_type[doc_type]["count"] += 1
total_documents += 1
# محاسبه از خطوط
for line in doc.lines:
summary_by_type[doc_type]["items_count"] += 1
total_items += 1
if line.movement == "in":
qty = Decimal(str(line.quantity))
summary_by_type[doc_type]["quantity_in"] += qty
total_quantity_in += qty
elif line.movement == "out":
qty = Decimal(str(line.quantity))
summary_by_type[doc_type]["quantity_out"] += qty
total_quantity_out += qty
items = []
for doc in documents:
items.append({
"id": doc.id,
"code": doc.code,
"document_date": doc.document_date,
"doc_type": doc.doc_type,
"status": doc.status,
"warehouse_from_id": doc.warehouse_id_from,
"warehouse_to_id": doc.warehouse_id_to,
"items_count": len(doc.lines),
"total_quantity": sum(Decimal(str(line.quantity)) for line in doc.lines),
})
return {
"items": items,
"total": total,
"summary": {
"by_type": {k: {
"count": v["count"],
"items_count": v["items_count"],
"quantity_in": float(v["quantity_in"]),
"quantity_out": float(v["quantity_out"]),
} for k, v in summary_by_type.items()},
"total_documents": total_documents,
"total_items": total_items,
"total_quantity_in": float(total_quantity_in),
"total_quantity_out": float(total_quantity_out),
}
}
def get_slow_moving_items_report(
db: Session,
business_id: int,
days_without_movement: int = 90,
warehouse_ids: Optional[List[int]] = None,
category_ids: Optional[List[int]] = None,
skip: int = 0,
take: int = 50,
) -> Dict[str, Any]:
"""
گزارش کالاهای کم‌گردش
"""
cutoff_date = date.today() - timedelta(days=days_without_movement)
# Query محصولات
query = db.query(Product).filter(Product.business_id == business_id)
if category_ids:
query = query.filter(Product.category_id.in_(category_ids))
products = query.all()
if not products:
return {
"items": [],
"total": 0,
"cutoff_date": cutoff_date.isoformat(),
}
product_ids = [p.id for p in products]
# پیدا کردن محصولاتی که در بازه زمانی حرکت داشته‌اند
movements_query = db.query(WarehouseDocumentLine.product_id).distinct().join(
WarehouseDocument,
WarehouseDocument.id == WarehouseDocumentLine.warehouse_document_id
).filter(
and_(
WarehouseDocument.business_id == business_id,
WarehouseDocument.status == "posted",
WarehouseDocument.document_date >= cutoff_date,
WarehouseDocumentLine.product_id.in_(product_ids),
)
)
if warehouse_ids:
movements_query = movements_query.filter(
WarehouseDocumentLine.warehouse_id.in_(warehouse_ids)
)
products_with_movement = {row[0] for row in movements_query.all()}
# محصولات بدون حرکت
slow_moving = [p for p in products if p.id not in products_with_movement]
items = []
for product in slow_moving[skip:skip + take]:
# محاسبه موجودی فعلی
stock = _compute_available_stock(db, business_id, product.id, None, date.today())
items.append({
"product_id": product.id,
"product_code": product.code or "",
"product_name": product.name,
"category_id": product.category_id,
"current_stock": float(stock),
"unit": product.main_unit or "",
"days_without_movement": days_without_movement,
})
return {
"items": items,
"total": len(slow_moving),
"cutoff_date": cutoff_date.isoformat(),
}
def get_critical_stock_report(
db: Session,
business_id: int,
warehouse_ids: Optional[List[int]] = None,
category_ids: Optional[List[int]] = None,
as_of_date: Optional[str] = None,
skip: int = 0,
take: int = 50,
) -> Dict[str, Any]:
"""
گزارش کالاهای با موجودی بحرانی
"""
from app.services.transfer_service import _parse_iso_date as _parse_date
as_of_date_obj = date.today()
if as_of_date:
try:
as_of_date_obj = _parse_date(as_of_date) if isinstance(as_of_date, str) else as_of_date
except Exception:
pass
# Query محصولات با حداقل موجودی
query = db.query(Product).filter(
and_(
Product.business_id == business_id,
Product.track_inventory == True,
Product.reorder_point.isnot(None),
Product.reorder_point > 0,
)
)
if category_ids:
query = query.filter(Product.category_id.in_(category_ids))
products = query.all()
if not products:
return {
"items": [],
"total": 0,
"as_of_date": as_of_date_obj.isoformat(),
}
# محاسبه موجودی و مقایسه با حداقل
critical_items = []
for product in products:
if warehouse_ids:
for wh_id in warehouse_ids:
stock = _compute_available_stock(db, business_id, product.id, wh_id, as_of_date_obj)
reorder_point = Decimal(str(product.reorder_point or 0))
if stock < reorder_point:
critical_items.append({
"product": product,
"warehouse_id": wh_id,
"stock": stock,
"min_stock": reorder_point,
"difference": stock - reorder_point,
})
else:
stock = _compute_available_stock(db, business_id, product.id, None, as_of_date_obj)
reorder_point = Decimal(str(product.reorder_point or 0))
if stock < reorder_point:
critical_items.append({
"product": product,
"warehouse_id": None,
"stock": stock,
"min_stock": reorder_point,
"difference": stock - reorder_point,
})
# مرتب‌سازی بر اساس تفاوت (کمترین موجودی اول)
critical_items.sort(key=lambda x: x["stock"])
items = []
for item in critical_items[skip:skip + take]:
product = item["product"]
warehouse_name = None
if item["warehouse_id"]:
wh = db.query(Warehouse).filter(Warehouse.id == item["warehouse_id"]).first()
if wh:
warehouse_name = f"{wh.code} - {wh.name}"
items.append({
"product_id": product.id,
"product_code": product.code or "",
"product_name": product.name,
"category_id": product.category_id,
"warehouse_id": item["warehouse_id"],
"warehouse_name": warehouse_name,
"current_stock": float(item["stock"]),
"min_stock": float(item["min_stock"]),
"difference": float(item["difference"]),
"unit": product.main_unit or "",
})
return {
"items": items,
"total": len(critical_items),
"as_of_date": as_of_date_obj.isoformat(),
}
def get_inter_warehouse_transfers_report(
db: Session,
business_id: int,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
warehouse_from_ids: Optional[List[int]] = None,
warehouse_to_ids: Optional[List[int]] = None,
skip: int = 0,
take: int = 50,
) -> Dict[str, Any]:
"""
گزارش انتقالات بین انبارها
"""
from app.services.transfer_service import _parse_iso_date as _parse_date
query = db.query(WarehouseDocument).filter(
and_(
WarehouseDocument.business_id == business_id,
WarehouseDocument.doc_type == "transfer",
WarehouseDocument.status == "posted",
)
)
# فیلتر تاریخ
if date_from:
try:
query = query.filter(WarehouseDocument.document_date >= _parse_date(date_from))
except Exception:
pass
if date_to:
try:
query = query.filter(WarehouseDocument.document_date <= _parse_date(date_to))
except Exception:
pass
# فیلتر انبار مبدا
if warehouse_from_ids:
query = query.filter(WarehouseDocument.warehouse_id_from.in_(warehouse_from_ids))
# فیلتر انبار مقصد
if warehouse_to_ids:
query = query.filter(WarehouseDocument.warehouse_id_to.in_(warehouse_to_ids))
total = query.count()
documents = query.order_by(
WarehouseDocument.document_date.desc(),
WarehouseDocument.id.desc()
).offset(skip).limit(take).all()
# بارگذاری اطلاعات انبارها
warehouse_ids = set()
for doc in documents:
if doc.warehouse_id_from:
warehouse_ids.add(doc.warehouse_id_from)
if doc.warehouse_id_to:
warehouse_ids.add(doc.warehouse_id_to)
warehouses = {}
if warehouse_ids:
wh_list = db.query(Warehouse).filter(
and_(
Warehouse.business_id == business_id,
Warehouse.id.in_(list(warehouse_ids)),
)
).all()
for wh in wh_list:
warehouses[wh.id] = wh
items = []
for doc in documents:
warehouse_from_name = None
if doc.warehouse_id_from and doc.warehouse_id_from in warehouses:
wh = warehouses[doc.warehouse_id_from]
warehouse_from_name = f"{wh.code} - {wh.name}"
warehouse_to_name = None
if doc.warehouse_id_to and doc.warehouse_id_to in warehouses:
wh = warehouses[doc.warehouse_id_to]
warehouse_to_name = f"{wh.code} - {wh.name}"
# محاسبه تعداد اقلام و مقدار
items_count = len(doc.lines)
total_quantity = sum(Decimal(str(line.quantity)) for line in doc.lines)
items.append({
"id": doc.id,
"code": doc.code,
"document_date": doc.document_date,
"warehouse_from_id": doc.warehouse_id_from,
"warehouse_from_name": warehouse_from_name,
"warehouse_to_id": doc.warehouse_id_to,
"warehouse_to_name": warehouse_to_name,
"items_count": items_count,
"total_quantity": float(total_quantity),
"created_by_user_id": doc.created_by_user_id,
})
return {
"items": items,
"total": total,
}
def get_adjustment_documents_report(
db: Session,
business_id: int,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
warehouse_ids: Optional[List[int]] = None,
skip: int = 0,
take: int = 50,
) -> Dict[str, Any]:
"""
گزارش حواله‌های تعدیل
"""
from app.services.transfer_service import _parse_iso_date as _parse_date
query = db.query(WarehouseDocument).filter(
and_(
WarehouseDocument.business_id == business_id,
WarehouseDocument.doc_type == "adjustment",
WarehouseDocument.status == "posted",
)
)
# فیلتر تاریخ
if date_from:
try:
query = query.filter(WarehouseDocument.document_date >= _parse_date(date_from))
except Exception:
pass
if date_to:
try:
query = query.filter(WarehouseDocument.document_date <= _parse_date(date_to))
except Exception:
pass
total = query.count()
documents = query.order_by(
WarehouseDocument.document_date.desc(),
WarehouseDocument.id.desc()
).offset(skip).limit(take).all()
# محاسبه آمار تعدیل‌ها
total_increases = Decimal(0)
total_decreases = Decimal(0)
total_items = 0
items = []
for doc in documents:
increases = Decimal(0)
decreases = Decimal(0)
items_count = 0
for line in doc.lines:
items_count += 1
qty = Decimal(str(line.quantity))
if line.movement == "in":
increases += qty
elif line.movement == "out":
decreases += qty
total_increases += increases
total_decreases += decreases
total_items += items_count
items.append({
"id": doc.id,
"code": doc.code,
"document_date": doc.document_date,
"items_count": items_count,
"quantity_increase": float(increases),
"quantity_decrease": float(decreases),
"net_adjustment": float(increases - decreases),
"created_by_user_id": doc.created_by_user_id,
})
return {
"items": items,
"total": total,
"summary": {
"total_documents": total,
"total_items": total_items,
"total_increases": float(total_increases),
"total_decreases": float(total_decreases),
"net_adjustment": float(total_increases - total_decreases),
}
}
def get_warehouse_performance_report(
db: Session,
business_id: int,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
warehouse_ids: Optional[List[int]] = None,
) -> Dict[str, Any]:
"""
گزارش عملکرد انبارها
"""
from app.services.transfer_service import _parse_iso_date as _parse_date
# Query انبارها
query = db.query(Warehouse).filter(Warehouse.business_id == business_id)
if warehouse_ids:
query = query.filter(Warehouse.id.in_(warehouse_ids))
warehouses = query.all()
if not warehouses:
return {
"items": [],
}
# فیلتر تاریخ برای حواله‌ها
date_from_obj = None
date_to_obj = None
if date_from:
try:
date_from_obj = _parse_date(date_from)
except Exception:
pass
if date_to:
try:
date_to_obj = _parse_date(date_to)
except Exception:
pass
items = []
for warehouse in warehouses:
# Query حواله‌های این انبار
docs_query = db.query(WarehouseDocument).filter(
and_(
WarehouseDocument.business_id == business_id,
WarehouseDocument.status == "posted",
or_(
WarehouseDocument.warehouse_id_from == warehouse.id,
WarehouseDocument.warehouse_id_to == warehouse.id,
)
)
)
if date_from_obj:
docs_query = docs_query.filter(WarehouseDocument.document_date >= date_from_obj)
if date_to_obj:
docs_query = docs_query.filter(WarehouseDocument.document_date <= date_to_obj)
documents = docs_query.all()
# محاسبه آمار
total_documents = len(documents)
total_items = 0
total_quantity_in = Decimal(0)
total_quantity_out = Decimal(0)
for doc in documents:
for line in doc.lines:
# تعیین اینکه آیا این خط مربوط به این انبار است یا نه
# اگر warehouse_id در خط مشخص باشد، از آن استفاده می‌کنیم
# در غیر این صورت، از warehouse_id_from یا warehouse_id_to حواله استفاده می‌کنیم
line_warehouse_id = line.warehouse_id
if line_warehouse_id is None:
# اگر warehouse_id در خط مشخص نباشد، از منطق حواله استفاده می‌کنیم
if doc.doc_type == "transfer":
# برای انتقال: خط out از مبدا و خط in به مقصد است
if line.movement == "out" and doc.warehouse_id_from == warehouse.id:
line_warehouse_id = warehouse.id
elif line.movement == "in" and doc.warehouse_id_to == warehouse.id:
line_warehouse_id = warehouse.id
elif doc.doc_type in ("receipt", "production_in"):
# برای ورود: خطوط به warehouse_id_to می‌روند
if line.movement == "in" and doc.warehouse_id_to == warehouse.id:
line_warehouse_id = warehouse.id
elif doc.doc_type in ("issue", "production_out"):
# برای خروج: خطوط از warehouse_id_from می‌آیند
if line.movement == "out" and doc.warehouse_id_from == warehouse.id:
line_warehouse_id = warehouse.id
else:
# برای سایر حواله‌ها (مثل adjustment): از warehouse_id_from یا warehouse_id_to استفاده می‌کنیم
if doc.warehouse_id_from == warehouse.id or doc.warehouse_id_to == warehouse.id:
line_warehouse_id = warehouse.id
# فقط خطوط مربوط به این انبار
if line_warehouse_id == warehouse.id:
total_items += 1
qty = Decimal(str(line.quantity))
if line.movement == "in":
total_quantity_in += qty
elif line.movement == "out":
total_quantity_out += qty
# محاسبه موجودی فعلی
# این نیاز به محاسبه از تمام محصولات دارد که می‌تواند سنگین باشد
# برای سادگی، فقط تعداد محصولات با موجودی را می‌شماریم
items.append({
"warehouse_id": warehouse.id,
"warehouse_code": warehouse.code,
"warehouse_name": warehouse.name,
"total_documents": total_documents,
"total_items": total_items,
"total_quantity_in": float(total_quantity_in),
"total_quantity_out": float(total_quantity_out),
"net_quantity": float(total_quantity_in - total_quantity_out),
})
return {
"items": items,
}
def get_product_movement_history_report(
db: Session,
business_id: int,
product_id: int,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
warehouse_ids: Optional[List[int]] = None,
skip: int = 0,
take: int = 50,
) -> Dict[str, Any]:
"""
گزارش تاریخچه حرکات یک کالا
"""
from app.services.transfer_service import _parse_iso_date as _parse_date
# بررسی وجود محصول
product = db.query(Product).filter(
and_(
Product.id == product_id,
Product.business_id == business_id,
)
).first()
if not product:
raise ApiError("PRODUCT_NOT_FOUND", "Product not found", http_status=404)
# Query خطوط حواله‌های مربوط به این محصول
query = db.query(WarehouseDocumentLine).join(
WarehouseDocument,
WarehouseDocument.id == WarehouseDocumentLine.warehouse_document_id
).filter(
and_(
WarehouseDocument.business_id == business_id,
WarehouseDocument.status == "posted",
WarehouseDocumentLine.product_id == product_id,
)
)
# فیلتر تاریخ
if date_from:
try:
query = query.filter(WarehouseDocument.document_date >= _parse_date(date_from))
except Exception:
pass
if date_to:
try:
query = query.filter(WarehouseDocument.document_date <= _parse_date(date_to))
except Exception:
pass
# فیلتر انبار
if warehouse_ids:
query = query.filter(WarehouseDocumentLine.warehouse_id.in_(warehouse_ids))
total = query.count()
lines = query.order_by(
WarehouseDocument.document_date.desc(),
WarehouseDocument.id.desc(),
WarehouseDocumentLine.id.desc()
).offset(skip).limit(take).all()
# بارگذاری اطلاعات انبارها
warehouse_ids_set = {line.warehouse_id for line in lines if line.warehouse_id}
warehouses = {}
if warehouse_ids_set:
wh_list = db.query(Warehouse).filter(
and_(
Warehouse.business_id == business_id,
Warehouse.id.in_(list(warehouse_ids_set)),
)
).all()
for wh in wh_list:
warehouses[wh.id] = wh
items = []
for line in lines:
doc = line.document
warehouse_name = None
if line.warehouse_id and line.warehouse_id in warehouses:
wh = warehouses[line.warehouse_id]
warehouse_name = f"{wh.code} - {wh.name}"
items.append({
"id": line.id,
"document_id": doc.id,
"document_code": doc.code,
"document_date": doc.document_date,
"doc_type": doc.doc_type,
"warehouse_id": line.warehouse_id,
"warehouse_name": warehouse_name,
"movement": line.movement,
"quantity": float(line.quantity),
"created_by_user_id": doc.created_by_user_id,
})
return {
"product_id": product.id,
"product_code": product.code or "",
"product_name": product.name,
"items": items,
"total": total,
}
def get_inventory_valuation_report(
db: Session,
business_id: int,
as_of_date: Optional[str] = None,
warehouse_ids: Optional[List[int]] = None,
category_ids: Optional[List[int]] = None,
currency_id: Optional[int] = None,
skip: int = 0,
take: int = 50,
) -> Dict[str, Any]:
"""
گزارش ارزش موجودی انبار، همواره معادل ارز پایهٔ کسب‌وکار.
currency_id پذیرفته می‌شود تا قرارداد گزارش‌ها یکسان باشد، اما فیلتر
معناداری برای آن وجود ندارد: ارزش تمام اقلام به ارز پایه محاسبه می‌شود.
"""
from app.services.transfer_service import _parse_iso_date as _parse_date
from app.services.business_currency_rate_service import resolve_rate_to_base
as_of_date_obj = date.today()
if as_of_date:
try:
as_of_date_obj = _parse_date(as_of_date) if isinstance(as_of_date, str) else as_of_date
except Exception:
pass
business = db.get(Business, int(business_id))
base_currency_id = (
int(business.default_currency_id)
if business and business.default_currency_id is not None
else None
)
base_currency = db.get(Currency, base_currency_id) if base_currency_id is not None else None
currency_codes: Dict[int, Optional[str]] = {}
rate_cache: Dict[int, Decimal] = {}
missing_fx_rate_product_ids: List[int] = []
def _currency_code(currency_pk: Optional[int]) -> Optional[str]:
if currency_pk is None:
return None
if currency_pk not in currency_codes:
currency = db.get(Currency, int(currency_pk))
currency_codes[currency_pk] = getattr(currency, "code", None)
return currency_codes[currency_pk]
def _as_decimal(value: Any) -> Decimal:
try:
return Decimal(str(value or 0))
except Exception:
return Decimal(0)
def _cost_in_base(product: Product) -> tuple[Decimal, Decimal, Optional[int], str]:
"""
اولویت با قیمت خرید ارزی است تا هزینهٔ منبع با نرخ گزارش به ارز پایه
تبدیل شود؛ در غیر این صورت قیمت خرید پایه (یا نام‌های legacy) پایه است.
"""
fx_cost = getattr(product, "purchase_price_fx", None)
fx_currency_id = getattr(product, "price_fx_currency_id", None)
if fx_cost is not None and fx_currency_id is not None:
source_cost = _as_decimal(fx_cost)
source_currency_id = int(fx_currency_id)
if base_currency_id is None or source_currency_id == base_currency_id:
return source_cost, source_cost, source_currency_id, "purchase_price_fx"
if source_currency_id not in rate_cache:
try:
result = resolve_rate_to_base(
db,
int(business_id),
source_currency_id,
datetime.combine(as_of_date_obj, datetime.max.time()),
)
rate_cache[source_currency_id] = _as_decimal(result.get("rate"))
except Exception:
rate_cache[source_currency_id] = Decimal(0)
rate = rate_cache[source_currency_id]
if rate > 0:
return (
source_cost * rate,
source_cost,
source_currency_id,
"purchase_price_fx",
)
missing_fx_rate_product_ids.append(int(product.id))
base_cost = _as_decimal(
getattr(product, "base_purchase_price", None)
or getattr(product, "cost_price", None)
or getattr(product, "purchase_price", None)
)
return base_cost, base_cost, base_currency_id, "base_purchase_price"
meta = {
"currency_id": currency_id,
"amounts_in_base": True,
"base_currency_id": base_currency_id,
"base_currency_code": getattr(base_currency, "code", None),
"currency_code": getattr(base_currency, "code", None),
"cost_price_note": (
"cost_price and value are reported in the business base currency; "
"purchase_price_fx is converted using the rate as of the report date."
),
"currency_filter_applied": False,
"currency_filter_note": "Inventory valuation is always reported in the business base currency.",
}
# Query محصولات
query = db.query(Product).filter(Product.business_id == business_id)
if category_ids:
query = query.filter(Product.category_id.in_(category_ids))
products = query.all()
if not products:
return {
"items": [],
"total": 0,
"total_value": 0.0,
"as_of_date": as_of_date_obj.isoformat(),
"meta": {**meta, "products_with_missing_fx_rate": []},
}
# Query انبارها
warehouses = []
if warehouse_ids:
warehouses = db.query(Warehouse).filter(
and_(
Warehouse.business_id == business_id,
Warehouse.id.in_(warehouse_ids),
)
).all()
else:
warehouses = db.query(Warehouse).filter(Warehouse.business_id == business_id).all()
items = []
total_value = Decimal(0)
for product in products:
# محاسبه موجودی و ارزش
if warehouses:
for warehouse in warehouses:
stock = _compute_available_stock(db, business_id, product.id, warehouse.id, as_of_date_obj)
if stock > 0:
cost_price, source_cost_price, cost_currency_id, cost_source = _cost_in_base(product)
value = Decimal(str(stock)) * cost_price
total_value += value
items.append({
"product_id": product.id,
"product_code": product.code or "",
"product_name": product.name,
"category_id": product.category_id,
"warehouse_id": warehouse.id,
"warehouse_code": warehouse.code,
"warehouse_name": warehouse.name,
"quantity": float(stock),
"unit": product.main_unit or "",
"cost_price": float(cost_price),
"currency_id": base_currency_id,
"currency_code": getattr(base_currency, "code", None),
"source_cost_price": float(source_cost_price),
"cost_currency_id": cost_currency_id,
"cost_currency_code": _currency_code(cost_currency_id),
"cost_source": cost_source,
"value": float(value),
"amounts_in_base": True,
})
else:
stock = _compute_available_stock(db, business_id, product.id, None, as_of_date_obj)
if stock > 0:
cost_price, source_cost_price, cost_currency_id, cost_source = _cost_in_base(product)
value = Decimal(str(stock)) * cost_price
total_value += value
items.append({
"product_id": product.id,
"product_code": product.code or "",
"product_name": product.name,
"category_id": product.category_id,
"warehouse_id": None,
"warehouse_code": None,
"warehouse_name": "کل",
"quantity": float(stock),
"unit": product.main_unit or "",
"cost_price": float(cost_price),
"currency_id": base_currency_id,
"currency_code": getattr(base_currency, "code", None),
"source_cost_price": float(source_cost_price),
"cost_currency_id": cost_currency_id,
"cost_currency_code": _currency_code(cost_currency_id),
"cost_source": cost_source,
"value": float(value),
"amounts_in_base": True,
})
# مرتب‌سازی بر اساس ارزش
items.sort(key=lambda x: x["value"], reverse=True)
return {
"items": items[skip:skip + take],
"total": len(items),
"total_value": float(total_value),
"as_of_date": as_of_date_obj.isoformat(),
"meta": {
**meta,
"products_with_missing_fx_rate": sorted(set(missing_fx_rate_product_ids)),
},
}
def get_pending_documents_report(
db: Session,
business_id: int,
warehouse_ids: Optional[List[int]] = None,
skip: int = 0,
take: int = 50,
) -> Dict[str, Any]:
"""
گزارش حواله‌های در انتظار تایید
"""
query = db.query(WarehouseDocument).filter(
and_(
WarehouseDocument.business_id == business_id,
WarehouseDocument.status == "draft",
)
)
# فیلتر انبار
if warehouse_ids:
query = query.filter(
or_(
WarehouseDocument.warehouse_id_from.in_(warehouse_ids),
WarehouseDocument.warehouse_id_to.in_(warehouse_ids),
)
)
total = query.count()
documents = query.order_by(
WarehouseDocument.created_at.asc(),
WarehouseDocument.id.asc()
).offset(skip).limit(take).all()
items = []
for doc in documents:
# محاسبه مدت زمان انتظار
days_pending = (date.today() - doc.created_at.date()).days
items.append({
"id": doc.id,
"code": doc.code,
"document_date": doc.document_date,
"doc_type": doc.doc_type,
"warehouse_from_id": doc.warehouse_id_from,
"warehouse_to_id": doc.warehouse_id_to,
"items_count": len(doc.lines),
"created_at": doc.created_at,
"created_by_user_id": doc.created_by_user_id,
"days_pending": days_pending,
})
return {
"items": items,
"total": total,
}
def get_inventory_turnover_report(
db: Session,
business_id: int,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
warehouse_ids: Optional[List[int]] = None,
category_ids: Optional[List[int]] = None,
skip: int = 0,
take: int = 50,
) -> Dict[str, Any]:
"""
گزارش گردش موجودی
"""
from app.services.transfer_service import _parse_iso_date as _parse_date
date_from_obj = date.today() - timedelta(days=365)
date_to_obj = date.today()
if date_from:
try:
date_from_obj = _parse_date(date_from)
except Exception:
pass
if date_to:
try:
date_to_obj = _parse_date(date_to)
except Exception:
pass
# Query محصولات
query = db.query(Product).filter(Product.business_id == business_id)
if category_ids:
query = query.filter(Product.category_id.in_(category_ids))
products = query.all()
if not products:
return {
"items": [],
"total": 0,
"pagination": {
"total": 0,
"page": 1,
"per_page": take,
"total_pages": 0,
},
# Backward-compat fields (some clients read these)
"page": 1,
"limit": take,
"total_pages": 0,
}
product_ids = [p.id for p in products]
# محاسبه گردش برای هر محصول
items = []
for product in products:
# محاسبه موجودی متوسط
# برای سادگی، از موجودی فعلی استفاده می‌کنیم
avg_stock = _compute_available_stock(db, business_id, product.id, None, date_to_obj)
# محاسبه تعداد خروج در دوره
out_query = db.query(func.sum(WarehouseDocumentLine.quantity)).join(
WarehouseDocument,
WarehouseDocument.id == WarehouseDocumentLine.warehouse_document_id
).filter(
and_(
WarehouseDocument.business_id == business_id,
WarehouseDocument.status == "posted",
WarehouseDocument.document_date >= date_from_obj,
WarehouseDocument.document_date <= date_to_obj,
WarehouseDocumentLine.product_id == product.id,
WarehouseDocumentLine.movement == "out",
)
)
if warehouse_ids:
out_query = out_query.filter(
WarehouseDocumentLine.warehouse_id.in_(warehouse_ids)
)
total_out = out_query.scalar() or Decimal(0)
# محاسبه نرخ گردش
turnover_rate = 0.0
if avg_stock > 0:
turnover_rate = float(total_out) / float(avg_stock)
items.append({
"product_id": product.id,
"product_code": product.code or "",
"product_name": product.name,
"category_id": product.category_id,
"average_stock": float(avg_stock),
"total_out": float(total_out),
"turnover_rate": turnover_rate,
"unit": product.main_unit or "",
})
# مرتب‌سازی بر اساس نرخ گردش
items.sort(key=lambda x: x["turnover_rate"], reverse=True)
total = len(items)
per_page = max(int(take or 0), 0)
safe_per_page = per_page if per_page > 0 else 50
safe_skip = max(int(skip or 0), 0)
page = (safe_skip // safe_per_page) + 1
total_pages = (total + safe_per_page - 1) // safe_per_page if total > 0 else 0
sliced = items[safe_skip:safe_skip + safe_per_page]
return {
"items": sliced,
"total": total,
"date_from": date_from_obj.isoformat(),
"date_to": date_to_obj.isoformat(),
# New shape used by `DataTableResponse.fromJson` (preferred)
"pagination": {
"total": total,
"page": page,
"per_page": safe_per_page,
"total_pages": total_pages,
},
# Old shape (compat)
"page": page,
"limit": safe_per_page,
"total_pages": total_pages,
}