Watch
1
0
Fork
You've already forked Seyyed_arc
0
forked from hesabix/arc
Seyyed_arc/hesabixAPI/app/services/ai/function_registry.py
2026-08-21 00:15:57 +00:00

1907 lines
92 KiB
Python
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

from __future__ import annotations
import hashlib
import json
from typing import Dict, Any, List, Callable, Optional, Set, Tuple
from dataclasses import dataclass
from enum import Enum
from sqlalchemy.orm import Session
from app.core.auth_dependency import AuthContext
from app.services.ai.ai_handler_convention import wrap_registry_service_func
class AIRole(str, Enum):
"""نقش‌های مختلف برای AI Functions"""
USER = "user" # کاربران عادی کسب‌وکار
OPERATOR = "operator" # اپراتورهای پشتیبانی
ADMIN = "admin" # مدیر سیستم (superadmin)
BUSINESS_OWNER = "business_owner" # مالک کسب‌وکار
@dataclass
class AIFunction:
"""تعریف یک function قابل استفاده توسط AI"""
name: str
description: str
parameters_schema: Dict[str, Any]
handler: Callable
allowed_roles: Set[AIRole]
required_permissions: Optional[List[str]] = None
business_context_required: bool = True
category: Optional[str] = None
# ---- فیلدهای امنیتی و بهینه‌سازی ----
requires_approval: bool = False # نیاز به تأیید صریح کاربر قبل از اجرا
risk_level: str = "safe" # safe / medium / high
is_readonly: bool = True # قابل کش شدن — عملیات read-only
is_agent_internal: bool = False # state داخلی agent — در حالت تحلیلگر هم مجاز
# ---- Metadata واحد Discovery / Intent / Security (منبع: Tool Manifest) ----
domains: Tuple[str, ...] = ()
capability: str = "misc"
namespace: str = ""
aliases: Tuple[str, ...] = ()
keywords: Tuple[str, ...] = ()
examples: Tuple[str, ...] = ()
companion_tools: Tuple[str, ...] = ()
is_core: bool = False
intent_write: bool = False
always_confirm: bool = False
side_effect: str = "none"
permission_policy: str = "required"
enabled: bool = True
deprecated: bool = False
replacement: Optional[str] = None
version: str = "1"
schema_version: str = "1"
def search_text(self) -> str:
parts = [
self.name,
self.description or "",
self.capability,
self.namespace,
*self.domains,
*self.aliases,
*self.keywords,
*self.examples,
]
return " ".join(part for part in parts if part)
def _has_filter_property(query: Dict[str, Any], prop: str) -> bool:
for item in query.get("filters") or []:
if isinstance(item, dict) and str(item.get("property")) == prop:
return True
return False
class AIFunctionRegistry:
"""
Registry مرکزی برای تمام function های AI
این registry به صورت lazy load می‌شود و از service layer استفاده می‌کند
"""
_instance: Optional['AIFunctionRegistry'] = None
_functions: Dict[str, AIFunction] = {}
_initialized: bool = False
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
if not self._initialized:
self._register_all_functions()
self._initialized = True
def _register_all_functions(self):
"""ثبت تمام function ها - این متد فقط یکبار اجرا می‌شود"""
# Business & Financial Functions
self._register_business_functions()
self._register_invoice_functions()
self._register_product_functions()
self._register_person_functions()
self._register_financial_functions()
self._register_crm_functions()
# Operator functions
self._register_operator_functions()
# Admin functions
self._register_admin_functions()
# Business owner functions
self._register_business_owner_functions()
# Extended business queries (warehouse, checks, generic query, ...)
from app.services.ai.ai_function_extensions import register_extended_business_functions
register_extended_business_functions(self)
from app.services.ai.ai_function_extensions_phase2 import (
register_phase2_business_functions,
)
register_phase2_business_functions(self)
from app.services.ai.ai_function_extensions_phase3 import (
register_phase3_business_functions,
)
register_phase3_business_functions(self)
from app.services.ai.ai_function_extensions_phase4 import (
register_phase4_business_functions,
)
register_phase4_business_functions(self)
from app.services.ai.ai_function_extensions_phase5 import (
register_phase5_business_functions,
)
register_phase5_business_functions(self)
from app.services.ai.ai_function_extensions_phase6 import (
register_phase6_business_functions,
)
register_phase6_business_functions(self)
from app.services.ai.ai_function_extensions_phase7 import (
register_phase7_business_functions,
)
register_phase7_business_functions(self)
from app.services.ai.ai_function_extensions_phase8 import (
register_phase8_business_functions,
)
register_phase8_business_functions(self)
from app.services.ai.ai_function_extensions_phase10 import (
register_phase10_business_functions,
)
register_phase10_business_functions(self)
from app.services.ai.ai_function_extensions_phase11 import (
register_phase11_business_functions,
)
register_phase11_business_functions(self)
from app.services.ai.ai_function_extensions_memory import register_memory_functions
register_memory_functions(self)
from app.services.ai.ai_function_extensions_session_todos import (
register_session_todo_functions,
)
register_session_todo_functions(self)
from app.services.ai.ai_function_extensions_subagent import (
register_subagent_functions,
)
register_subagent_functions(self)
from app.services.ai.ai_function_extensions_workflow import (
register_workflow_ai_functions,
)
register_workflow_ai_functions(self)
from app.services.ai.ai_function_extensions_hscript import (
register_hscript_ai_functions,
)
register_hscript_ai_functions(self)
# External HTTP connectors
self._register_connector_functions()
def _register_business_functions(self):
"""ثبت function های مربوط به کسب‌وکار"""
from app.services.business_service import get_business_by_id
def get_business_wrapper(db, business_id, user_id, **kwargs):
"""Wrapper برای دریافت اطلاعات کسب‌وکار"""
return get_business_by_id(db, business_id, user_id)
self.register(AIFunction(
name="get_business_info",
description="دریافت اطلاعات کامل کسب‌وکار فعلی شامل نام، آدرس، اطلاعات تماس و تنظیمات. شناسه کسب‌وکار به صورت خودکار از جلسه گفت‌وگو گرفته می‌شود.",
parameters_schema={
"type": "object",
"properties": {},
"required": []
},
handler=self._create_handler(get_business_wrapper),
allowed_roles={AIRole.USER, AIRole.BUSINESS_OWNER, AIRole.OPERATOR, AIRole.ADMIN},
business_context_required=True,
category="business"
))
def _register_invoice_functions(self):
"""ثبت function های مربوط به فاکتورها"""
def search_invoices_wrapper(db, business_id, user_id, **kwargs):
"""Wrapper برای جستجوی فاکتورها با QueryInfo کامل."""
from app.services.ai.ai_tool_query_params import build_ai_list_query
from app.services.document_service import list_documents
query = build_ai_list_query(kwargs, entity="invoice")
if kwargs.get("document_type"):
query["document_type"] = kwargs["document_type"]
elif not query.get("document_type"):
query["document_type"] = "invoice_sales"
if kwargs.get("person_id") is not None:
query["person_id"] = kwargs["person_id"]
return list_documents(db, business_id, query)
from app.services.ai.ai_tool_query_params import ai_list_parameters_schema
self.register(AIFunction(
name="search_invoices",
description=(
"جستجو و فیلتر فاکتورها (QueryInfo: search, search_fields, filters با عملگر = > < * in). "
"list_queryable_fields(entity=invoice) برای ستون‌های مجاز. "
"نتیجه: items + pagination.total. take حداکثر ۱۰۰."
),
parameters_schema=ai_list_parameters_schema(
entity="invoice",
extra_properties={
"document_type": {
"type": "string",
"enum": [
"invoice_sales",
"invoice_purchase",
"invoice_sales_return",
"invoice_purchase_return",
],
"description": "نوع فاکتور (اختیاری؛ پیش‌فرض invoice_sales)",
},
"person_id": {"type": "integer", "description": "شناسه مشتری/تامین‌کننده"},
"is_proforma": {"type": "boolean", "description": "فقط پیش‌فاکتور"},
},
),
handler=self._create_handler(search_invoices_wrapper),
allowed_roles={AIRole.USER, AIRole.BUSINESS_OWNER, AIRole.OPERATOR, AIRole.ADMIN},
required_permissions=["invoices.read"],
category="invoices"
))
# اضافه کردن get_invoice_details
def get_invoice_details_wrapper(db, business_id, user_id, invoice_id, **kwargs):
"""Wrapper برای دریافت جزئیات فاکتور"""
from app.services.invoice_service import invoice_document_to_dict
from adapters.db.models.document import Document
document = db.query(Document).filter(
Document.id == invoice_id,
Document.business_id == business_id
).first()
if not document:
raise ValueError(f"Invoice {invoice_id} not found")
# بررسی نوع سند - باید فاکتور باشد
invoice_types = [
"invoice_sales", "invoice_sales_return",
"invoice_purchase", "invoice_purchase_return",
"invoice_direct_consumption", "invoice_production", "invoice_waste"
]
if document.document_type not in invoice_types:
raise ValueError(f"Document {invoice_id} is not an invoice")
return invoice_document_to_dict(db, document)
self.register(AIFunction(
name="get_invoice_details",
description="دریافت جزئیات کامل یک فاکتور شامل اقلام، مالیات، پرداخت‌ها و سایر اطلاعات. شناسه کسب‌وکار به صورت خودکار از جلسه گفت‌وگو گرفته می‌شود.",
parameters_schema={
"type": "object",
"properties": {
"invoice_id": {"type": "integer", "description": "شناسه فاکتور"}
},
"required": ["invoice_id"]
},
handler=self._create_handler(get_invoice_details_wrapper),
allowed_roles={AIRole.USER, AIRole.BUSINESS_OWNER, AIRole.OPERATOR, AIRole.ADMIN},
required_permissions=["invoices.read"],
category="invoices"
))
# اضافه کردن get_invoices_count
def get_invoices_count_wrapper(db, business_id, user_id, **kwargs):
"""Wrapper برای دریافت تعداد فاکتورها"""
from app.services.document_service import list_documents
# فیلتر کردن فقط فاکتورها
invoice_types = [
"invoice_sales", "invoice_sales_return",
"invoice_purchase", "invoice_purchase_return",
"invoice_direct_consumption", "invoice_production", "invoice_waste"
]
# ساخت query dict برای list_documents
query = {}
# document_type
if "document_type" in kwargs:
query["document_type"] = kwargs["document_type"]
elif "document_types" in kwargs:
doc_types = kwargs["document_types"]
if isinstance(doc_types, list) and doc_types:
query["document_type"] = doc_types[0]
# سایر فیلترها
if "fiscal_year_id" in kwargs:
query["fiscal_year_id"] = kwargs["fiscal_year_id"]
if "from_date" in kwargs:
query["from_date"] = kwargs["from_date"]
if "to_date" in kwargs:
query["to_date"] = kwargs["to_date"]
if "person_id" in kwargs:
query["person_id"] = kwargs["person_id"]
if "search" in kwargs:
query["search"] = kwargs["search"]
# برای دریافت تعداد، فقط یک رکورد می‌خواهیم
query["take"] = 1
query["skip"] = 0
result = list_documents(db, business_id, query)
total = result.get("pagination", {}).get("total", 0)
return {
"total": total,
"filters_applied": {
"document_type": query.get("document_type"),
"fiscal_year_id": query.get("fiscal_year_id"),
"from_date": query.get("from_date"),
"to_date": query.get("to_date"),
"person_id": query.get("person_id")
}
}
self.register(AIFunction(
name="get_invoices_count",
description="دریافت تعداد کل فاکتورها بر اساس فیلترهای انتخابی (تاریخ، نوع، مشتری و غیره). شناسه کسب‌وکار به صورت خودکار از جلسه گفت‌وگو گرفته می‌شود. این function برای پاسخ به سوالات مربوط به تعداد فاکتورها استفاده می‌شود.",
parameters_schema={
"type": "object",
"properties": {
"fiscal_year_id": {"type": "integer", "description": "شناسه سال مالی (اختیاری)"},
"from_date": {"type": "string", "format": "date", "description": "تاریخ شروع (اختیاری)"},
"to_date": {"type": "string", "format": "date", "description": "تاریخ پایان (اختیاری)"},
"document_type": {
"type": "string",
"enum": ["invoice_sales", "invoice_purchase", "invoice_sales_return", "invoice_purchase_return", "invoice_direct_consumption", "invoice_production", "invoice_waste"],
"description": "نوع فاکتور (اختیاری)"
},
"person_id": {"type": "integer", "description": "شناسه مشتری/تامین‌کننده (اختیاری)"}
},
"required": []
},
handler=self._create_handler(get_invoices_count_wrapper),
allowed_roles={AIRole.USER, AIRole.BUSINESS_OWNER, AIRole.OPERATOR, AIRole.ADMIN},
required_permissions=["invoices.read"],
category="invoices"
))
# اضافه کردن create_invoice
def create_invoice_wrapper(args: Dict[str, Any], context: Dict[str, Any]) -> Any:
"""Wrapper برای ایجاد فاکتور — نگاشت unit_price/person_id به قرارداد سرویس."""
from app.services.ai.ai_tool_payloads import build_create_invoice_payload
from app.services.invoice_service import create_invoice
db: Session = context["db"]
user_context: AuthContext = context["user_context"]
business_id = args.get("business_id") or context.get("business_id")
user_id = user_context.get_user_id()
data = build_create_invoice_payload(
args, db=db, business_id=business_id
)
if data.get("invoice_type") in {
"invoice_sales",
"invoice_purchase",
"invoice_sales_return",
"invoice_purchase_return",
} and not data.get("person_id"):
raise ValueError(
"person_id الزامی است. ابتدا search_persons را صدا بزن و id عددی شخص را بفرست."
)
if not data.get("currency_id"):
raise ValueError(
"currency_id مشخص نیست. list_currencies را صدا بزن یا ارز پیش‌فرض کسب‌وکار را تنظیم کنید."
)
if not data.get("invoice_type"):
raise ValueError(
"invoice_type الزامی است: invoice_sales، invoice_purchase، "
"invoice_sales_return یا invoice_purchase_return."
)
return create_invoice(db, business_id, user_id, data)
from app.services.ai.ai_tool_payloads import (
CREATE_INVOICE_DESCRIPTION,
CREATE_INVOICE_PARAMETERS_SCHEMA,
)
self.register(AIFunction(
name="create_invoice",
description=CREATE_INVOICE_DESCRIPTION,
parameters_schema=CREATE_INVOICE_PARAMETERS_SCHEMA,
handler=self._create_handler(create_invoice_wrapper),
allowed_roles={AIRole.USER, AIRole.BUSINESS_OWNER, AIRole.OPERATOR, AIRole.ADMIN},
required_permissions=["invoices.write"],
category="invoices",
requires_approval=True,
risk_level="medium",
is_readonly=False,
))
def _register_product_functions(self):
"""ثبت function های مربوط به محصولات"""
from app.services.product_service import get_product, list_products
def search_products_wrapper(db, business_id, user_id, **kwargs):
from app.services.ai.ai_tool_query_params import build_ai_list_query
q = build_ai_list_query(kwargs, entity="product")
if kwargs.get("category_id") is not None:
q["category_ids"] = [int(kwargs["category_id"])]
if kwargs.get("item_type") and not _has_filter_property(q, "item_type"):
flt = list(q.get("filters") or [])
flt.append(
{"property": "item_type", "operator": "=", "value": kwargs["item_type"]}
)
q["filters"] = flt
if kwargs.get("track_inventory"):
q["include_inventory"] = True
return list_products(db, business_id, q)
from app.services.ai.ai_tool_query_params import ai_list_parameters_schema
self.register(AIFunction(
name="search_products",
description=(
"جستجو در کالا/خدمات با QueryInfo (filters, search_fields). "
"list_queryable_fields(entity=product). take حداکثر ۱۰۰."
),
parameters_schema=ai_list_parameters_schema(
entity="product",
extra_properties={
"category_id": {"type": "integer", "description": "فیلتر دسته‌بندی"},
"item_type": {
"type": "string",
"enum": ["product", "service"],
"description": "نوع (یا در filters)",
},
"track_inventory": {
"type": "boolean",
"description": "محاسبه موجودی در پاسخ",
},
"include_inventory": {"type": "boolean"},
},
),
handler=self._create_handler(search_products_wrapper),
allowed_roles={AIRole.USER, AIRole.BUSINESS_OWNER, AIRole.OPERATOR, AIRole.ADMIN},
required_permissions=["inventory.read"],
category="products"
))
def get_product_info_wrapper(db, business_id, user_id=None, product_id=None, **kwargs):
"""get_product امضای user_id ندارد — wrapper آرگومان‌های جلسه را می‌بلعد."""
pid = product_id if product_id is not None else kwargs.get("id")
if pid is None:
raise ValueError("product_id الزامی است. از search_products فیلد id را بردار.")
data = get_product(db, int(pid), int(business_id))
if not data:
raise ValueError(f"کالا/خدمت {pid} در این کسب‌وکار یافت نشد.")
return data
self.register(AIFunction(
name="get_product_info",
description=(
"دریافت اطلاعات کامل یک کالا یا خدمت با شناسه عددی. "
"product_id را از search_products بگیر. شناسه کسب‌وکار از جلسه تزریق می‌شود."
),
parameters_schema={
"type": "object",
"properties": {
"product_id": {"type": "integer", "description": "شناسه عددی کالا/خدمت از search_products"}
},
"required": ["product_id"]
},
handler=self._create_handler(get_product_info_wrapper),
allowed_roles={AIRole.USER, AIRole.BUSINESS_OWNER, AIRole.OPERATOR, AIRole.ADMIN},
required_permissions=["inventory.read"],
category="products"
))
# اضافه کردن get_inventory_status
def get_inventory_status_wrapper(db, business_id, user_id, **kwargs):
"""Wrapper برای دریافت وضعیت موجودی"""
from app.services.warehouse_service import get_warehouse_stock_report
query = {
"product_ids": kwargs.get("product_ids", []),
"warehouse_ids": kwargs.get("warehouse_ids", []),
"as_of_date": kwargs.get("as_of_date"),
"include_zero": kwargs.get("include_zero", False)
}
return get_warehouse_stock_report(db, business_id, query)
self.register(AIFunction(
name="get_inventory_status",
description="دریافت وضعیت موجودی محصولات در انبارها. شناسه کسب‌وکار به صورت خودکار از جلسه گفت‌وگو گرفته می‌شود.",
parameters_schema={
"type": "object",
"properties": {
"product_id": {"type": "integer", "description": "شناسه محصول (اختیاری - اگر مشخص نشود، لیست تمام محصولات)"},
"warehouse_id": {"type": "integer", "description": "شناسه انبار (اختیاری)"},
"as_of_date": {"type": "string", "format": "date", "description": "تاریخ محاسبه موجودی (اختیاری)"},
"include_zero": {"type": "boolean", "description": "نمایش محصولات با موجودی صفر (اختیاری)"}
},
"required": []
},
handler=self._create_handler(get_inventory_status_wrapper),
allowed_roles={AIRole.USER, AIRole.BUSINESS_OWNER, AIRole.OPERATOR, AIRole.ADMIN},
required_permissions=["inventory.read"],
category="products"
))
# اضافه کردن get_product_kardex
def get_product_kardex_wrapper(db, business_id, user_id, product_id, **kwargs):
"""Wrapper برای دریافت کاردکس محصول"""
from app.services.product_service import get_inventory_kardex_report
result = get_inventory_kardex_report(
db=db,
business_id=business_id,
fiscal_year_id=kwargs.get("fiscal_year_id"),
date_from=kwargs.get("from_date"),
date_to=kwargs.get("to_date"),
product_ids=[product_id],
warehouse_ids=[kwargs.get("warehouse_id")] if kwargs.get("warehouse_id") else None,
category_ids=None,
search=None,
skip=kwargs.get("skip", 0),
take=kwargs.get("take", 100)
)
return result
self.register(AIFunction(
name="get_product_kardex",
description="دریافت کاردکس (گردش موجودی) یک محصول در انبار. شناسه کسب‌وکار به صورت خودکار از جلسه گفت‌وگو گرفته می‌شود.",
parameters_schema={
"type": "object",
"properties": {
"product_id": {"type": "integer", "description": "شناسه محصول"},
"warehouse_id": {"type": "integer", "description": "شناسه انبار (اختیاری)"},
"from_date": {"type": "string", "format": "date", "description": "تاریخ شروع (اختیاری)"},
"to_date": {"type": "string", "format": "date", "description": "تاریخ پایان (اختیاری)"},
"fiscal_year_id": {"type": "integer", "description": "شناسه سال مالی (اختیاری)"}
},
"required": ["product_id"]
},
handler=self._create_handler(get_product_kardex_wrapper),
allowed_roles={AIRole.USER, AIRole.BUSINESS_OWNER, AIRole.OPERATOR, AIRole.ADMIN},
required_permissions=["inventory.read"],
category="products"
))
def _register_person_functions(self):
"""ثبت function های مربوط به اشخاص (مشتریان/تامین‌کنندگان)"""
from app.services.ai.ai_tool_query_params import ai_list_parameters_schema
from app.services.person_service import get_person_by_id, search_persons, calculate_person_balance
from app.services.person_service import get_debtors_report, get_creditors_report
from app.services.person_service import create_person, update_person
def get_person_wrapper(db, business_id, person_id, user_id, **kwargs):
"""Wrapper برای دریافت اطلاعات شخص"""
return get_person_by_id(db, person_id, business_id)
self.register(AIFunction(
name="get_customer_info",
description="دریافت اطلاعات کامل یک مشتری یا تامین‌کننده شامل اطلاعات تماس، اعتبار و تاریخچه. شناسه کسب‌وکار به صورت خودکار از جلسه گفت‌وگو گرفته می‌شود.",
parameters_schema={
"type": "object",
"properties": {
"person_id": {"type": "integer", "description": "شناسه مشتری یا تامین‌کننده"}
},
"required": ["person_id"]
},
handler=self._create_handler(get_person_wrapper),
allowed_roles={AIRole.USER, AIRole.BUSINESS_OWNER, AIRole.OPERATOR, AIRole.ADMIN},
required_permissions=["persons.read"],
category="persons"
))
# اضافه کردن search_persons
def search_persons_wrapper(db, business_id, user_id, **kwargs):
"""Wrapper برای جستجوی اشخاص با get_persons_by_business + QueryInfo."""
from app.services.ai.ai_tool_query_params import build_ai_list_query
from app.services.person_service import get_persons_by_business
q = build_ai_list_query(kwargs, entity="person")
if kwargs.get("page") and "skip" not in kwargs:
limit = int(kwargs.get("limit") or q.get("take") or 20)
page = max(1, int(kwargs["page"]))
q["skip"] = (page - 1) * limit
q["take"] = limit
pt = kwargs.get("person_type")
if pt and pt != "both" and not _has_filter_property(q, "person_types"):
from app.services.ai.ai_tool_payloads import normalize_person_type_value
try:
pt_value = normalize_person_type_value(pt)
except ValueError:
pt_value = pt
flt = list(q.get("filters") or [])
flt.append({"property": "person_types", "operator": "*", "value": pt_value})
q["filters"] = flt
return get_persons_by_business(
db,
business_id,
q,
fiscal_year_id=kwargs.get("fiscal_year_id"),
)
self.register(AIFunction(
name="search_persons",
description=(
"جستجو در اشخاص با QueryInfo (filters, search_fields). "
"list_queryable_fields(entity=person). پاسخ: items + pagination. take حداکثر ۱۰۰."
),
parameters_schema=ai_list_parameters_schema(
entity="person",
extra_properties={
"person_type": {
"type": "string",
"enum": [
"customer",
"supplier",
"marketer",
"employee",
"partner",
"seller",
"shareholder",
"both",
],
"description": "نوع شخص (یا فیلتر person_types)",
},
"page": {"type": "integer", "description": "شماره صفحه (جایگزین skip)"},
"limit": {"type": "integer", "description": "تعداد در صفحه"},
},
),
handler=self._create_handler(search_persons_wrapper),
allowed_roles={AIRole.USER, AIRole.BUSINESS_OWNER, AIRole.OPERATOR, AIRole.ADMIN},
required_permissions=["persons.read"],
category="persons"
))
# اضافه کردن get_person_balance
def get_person_balance_wrapper(db, business_id, person_id, user_id, **kwargs):
"""Wrapper برای دریافت موجودی شخص"""
fiscal_year_id = kwargs.get("fiscal_year_id")
balance, status = calculate_person_balance(db, person_id, fiscal_year_id)
return {
"person_id": person_id,
"balance": balance,
"status": status,
"fiscal_year_id": fiscal_year_id
}
self.register(AIFunction(
name="get_person_balance",
description="دریافت موجودی و بدهی/بستانکاری یک مشتری یا تامین‌کننده. شناسه کسب‌وکار به صورت خودکار از جلسه گفت‌وگو گرفته می‌شود.",
parameters_schema={
"type": "object",
"properties": {
"person_id": {"type": "integer", "description": "شناسه مشتری یا تامین‌کننده"},
"fiscal_year_id": {"type": "integer", "description": "شناسه سال مالی (اختیاری)"}
},
"required": ["person_id"]
},
handler=self._create_handler(get_person_balance_wrapper),
allowed_roles={AIRole.USER, AIRole.BUSINESS_OWNER, AIRole.OPERATOR, AIRole.ADMIN},
required_permissions=["persons.read"],
category="persons"
))
# اضافه کردن create_person / update_person — هم‌تراز با فرم UI
def _ensure_person_opening_balance_permission(
context: Dict[str, Any],
business_id: int,
opening_balance: Any,
) -> None:
if not opening_balance:
return
from app.core.permissions import has_business_permission_for_business
from app.core.responses import ApiError
user_context: AuthContext = context["user_context"]
db: Session = context["db"]
if not has_business_permission_for_business(
user_context, db, int(business_id), "opening_balance", "edit"
):
raise ApiError(
"OPENING_BALANCE_PERMISSION_REQUIRED",
"برای ثبت مانده افتتاحیه به دسترسی ویرایش تراز افتتاحیه نیاز است",
http_status=403,
)
def create_person_wrapper(args: Dict[str, Any], context: Dict[str, Any]) -> Any:
"""Wrapper برای ایجاد شخص با تمام فیلدهای فرم UI."""
from adapters.api.v1.schema_models.person import PersonCreateRequest
from app.services.ai.ai_tool_payloads import build_create_person_payload
from app.services.person_opening_balance_service import create_person_with_opening_balance
from app.services.person_service import delete_person
db: Session = context["db"]
user_context: AuthContext = context["user_context"]
business_id = args.get("business_id") or context.get("business_id")
person_data = PersonCreateRequest(**build_create_person_payload(args))
_ensure_person_opening_balance_permission(
context, business_id, getattr(person_data, "opening_balance", None)
)
if person_data.opening_balance is not None:
return create_person_with_opening_balance(
db,
business_id,
user_context.get_user_id(),
person_data,
create_person_fn=create_person,
delete_person_fn=delete_person,
)
return create_person(db, business_id, person_data)
from app.services.ai.ai_tool_payloads import (
CREATE_PERSON_DESCRIPTION,
CREATE_PERSON_PARAMETERS_SCHEMA,
UPDATE_PERSON_DESCRIPTION,
UPDATE_PERSON_PARAMETERS_SCHEMA,
)
self.register(AIFunction(
name="create_person",
description=CREATE_PERSON_DESCRIPTION,
parameters_schema=CREATE_PERSON_PARAMETERS_SCHEMA,
handler=self._create_handler(create_person_wrapper),
allowed_roles={AIRole.USER, AIRole.BUSINESS_OWNER, AIRole.OPERATOR, AIRole.ADMIN},
required_permissions=["persons.write"],
category="persons",
requires_approval=True,
risk_level="medium",
is_readonly=False,
))
def update_person_wrapper(args: Dict[str, Any], context: Dict[str, Any]) -> Any:
"""Wrapper برای ویرایش شخص با تمام فیلدهای فرم UI."""
from adapters.api.v1.schema_models.person import PersonUpdateRequest
from app.services.ai.ai_tool_payloads import build_update_person_payload
from app.services.person_opening_balance_service import update_person_with_opening_balance
db: Session = context["db"]
user_context: AuthContext = context["user_context"]
business_id = args.get("business_id") or context.get("business_id")
person_id = args.get("person_id")
if not person_id:
raise ValueError("person_id الزامی است.")
person_data = PersonUpdateRequest(**build_update_person_payload(args))
_ensure_person_opening_balance_permission(
context, business_id, getattr(person_data, "opening_balance", None)
)
if person_data.opening_balance is not None:
return update_person_with_opening_balance(
db,
business_id,
user_context.get_user_id(),
person_id,
person_data,
update_person_fn=update_person,
)
return update_person(db, person_id, business_id, person_data)
self.register(AIFunction(
name="update_person",
description=UPDATE_PERSON_DESCRIPTION,
parameters_schema=UPDATE_PERSON_PARAMETERS_SCHEMA,
handler=self._create_handler(update_person_wrapper),
allowed_roles={AIRole.USER, AIRole.BUSINESS_OWNER, AIRole.OPERATOR, AIRole.ADMIN},
required_permissions=["persons.write"],
category="persons",
requires_approval=True,
risk_level="medium",
is_readonly=False,
))
def _register_financial_functions(self):
"""ثبت function های مربوط به امور مالی"""
from app.services.ai.ai_tool_query_params import ai_list_parameters_schema
from app.services.business_dashboard_service import get_business_dashboard_data
from app.services.person_service import get_debtors_report, get_creditors_report
from app.services.receipt_payment_service import list_receipts_payments, create_receipt_payment
from app.services.product_service import get_sales_by_product_report
def get_financial_summary_wrapper(args: Dict[str, Any], context: Dict[str, Any]) -> Any:
"""Wrapper برای دریافت خلاصه مالی"""
db: Session = context["db"]
user_context: AuthContext = context["user_context"]
business_id = args.get("business_id") or context.get("business_id")
return get_business_dashboard_data(db, business_id, user_context)
self.register(AIFunction(
name="get_financial_summary",
description="دریافت خلاصه مالی کسب‌وکار فعلی شامل درآمد، هزینه، موجودی و سایر آمارها. شناسه کسب‌وکار به صورت خودکار از جلسه گفت‌وگو گرفته می‌شود.",
parameters_schema={
"type": "object",
"properties": {},
"required": []
},
handler=get_financial_summary_wrapper,
allowed_roles={AIRole.USER, AIRole.BUSINESS_OWNER, AIRole.ADMIN},
required_permissions=["reports.read"],
category="financial"
))
# اضافه کردن get_debtors_report
def get_debtors_report_wrapper(db, business_id, user_id, **kwargs):
"""Wrapper برای گزارش بدهکاران"""
return get_debtors_report(
db=db,
business_id=business_id,
fiscal_year_id=kwargs.get("fiscal_year_id"),
currency_id=kwargs.get("currency_id"),
date_from=kwargs.get("date_from"),
date_to=kwargs.get("date_to"),
min_balance=kwargs.get("min_balance"),
person_ids=kwargs.get("person_ids"),
search=kwargs.get("search"),
skip=kwargs.get("skip", 0),
take=kwargs.get("take", 50)
)
self.register(AIFunction(
name="get_debtors_report",
description="گزارش بدهکاران با جزئیات بدهی و تاریخچه. شناسه کسب‌وکار به صورت خودکار از جلسه گفت‌وگو گرفته می‌شود.",
parameters_schema={
"type": "object",
"properties": {
"fiscal_year_id": {"type": "integer", "description": "شناسه سال مالی (اختیاری)"},
"date_from": {"type": "string", "format": "date", "description": "از تاریخ (اختیاری)"},
"date_to": {"type": "string", "format": "date", "description": "تا تاریخ (اختیاری)"},
"min_balance": {"type": "number", "description": "حداقل بدهی (اختیاری)"},
"search": {"type": "string", "description": "جستجو در نام/کد (اختیاری)"}
},
"required": []
},
handler=self._create_handler(get_debtors_report_wrapper),
allowed_roles={AIRole.USER, AIRole.BUSINESS_OWNER, AIRole.OPERATOR, AIRole.ADMIN},
required_permissions=["reports.read"],
category="financial"
))
# اضافه کردن get_creditors_report
def get_creditors_report_wrapper(db, business_id, user_id, **kwargs):
"""Wrapper برای گزارش بستانکاران"""
return get_creditors_report(
db=db,
business_id=business_id,
fiscal_year_id=kwargs.get("fiscal_year_id"),
currency_id=kwargs.get("currency_id"),
date_from=kwargs.get("date_from"),
date_to=kwargs.get("date_to"),
min_balance=kwargs.get("min_balance"),
person_ids=kwargs.get("person_ids"),
search=kwargs.get("search"),
skip=kwargs.get("skip", 0),
take=kwargs.get("take", 50)
)
self.register(AIFunction(
name="get_creditors_report",
description="گزارش بستانکاران با جزئیات بستانکاری و تاریخچه. شناسه کسب‌وکار به صورت خودکار از جلسه گفت‌وگو گرفته می‌شود.",
parameters_schema={
"type": "object",
"properties": {
"fiscal_year_id": {"type": "integer", "description": "شناسه سال مالی (اختیاری)"},
"date_from": {"type": "string", "format": "date", "description": "از تاریخ (اختیاری)"},
"date_to": {"type": "string", "format": "date", "description": "تا تاریخ (اختیاری)"},
"min_balance": {"type": "number", "description": "حداقل بستانکاری (اختیاری)"},
"search": {"type": "string", "description": "جستجو در نام/کد (اختیاری)"}
},
"required": []
},
handler=self._create_handler(get_creditors_report_wrapper),
allowed_roles={AIRole.USER, AIRole.BUSINESS_OWNER, AIRole.OPERATOR, AIRole.ADMIN},
required_permissions=["reports.read"],
category="financial"
))
# اضافه کردن search_receipts_payments
def search_receipts_payments_wrapper(db, business_id, user_id, **kwargs):
"""Wrapper برای جستجوی دریافت/پرداخت با QueryInfo."""
from app.services.ai.ai_tool_query_params import build_ai_list_query
query = build_ai_list_query(kwargs, entity="document")
if kwargs.get("type"):
query["document_type"] = kwargs["type"]
if kwargs.get("account_type"):
query["account_type"] = kwargs["account_type"]
if kwargs.get("person_id") is not None:
query["person_id"] = kwargs["person_id"]
return list_receipts_payments(db, business_id, query)
self.register(AIFunction(
name="search_receipts_payments",
description=(
"جستجو در دریافت/پرداخت با QueryInfo (filters, search_fields). "
"type=receipt|payment. فیلترهای ستونی در filters[]. take حداکثر ۱۰۰."
),
parameters_schema=ai_list_parameters_schema(
entity="document",
extra_properties={
"type": {
"type": "string",
"enum": ["receipt", "payment"],
"description": "نوع سند",
},
"account_type": {
"type": "string",
"enum": ["bank", "cash", "petty_cash"],
"description": "نوع حساب طرف",
},
"person_id": {"type": "integer"},
},
),
handler=self._create_handler(search_receipts_payments_wrapper),
allowed_roles={AIRole.USER, AIRole.BUSINESS_OWNER, AIRole.OPERATOR, AIRole.ADMIN},
required_permissions=["receipts_payments.read"],
category="financial"
))
# اضافه کردن create_receipt_payment
def create_receipt_payment_wrapper(args: Dict[str, Any], context: Dict[str, Any]) -> Any:
"""Wrapper برای ایجاد دریافت/پرداخت — نگاشت bank_id نه کدینگ."""
from app.services.ai.ai_tool_payloads import build_create_receipt_payment_payload
db: Session = context["db"]
user_context: AuthContext = context["user_context"]
business_id = args.get("business_id") or context.get("business_id")
user_id = user_context.get_user_id()
data = build_create_receipt_payment_payload(
args, db=db, business_id=business_id
)
return create_receipt_payment(db, business_id, user_id, data)
from app.services.ai.ai_tool_payloads import (
CREATE_RECEIPT_PAYMENT_DESCRIPTION,
CREATE_RECEIPT_PAYMENT_PARAMETERS_SCHEMA,
)
self.register(AIFunction(
name="create_receipt_payment",
description=CREATE_RECEIPT_PAYMENT_DESCRIPTION,
parameters_schema=CREATE_RECEIPT_PAYMENT_PARAMETERS_SCHEMA,
handler=self._create_handler(create_receipt_payment_wrapper),
allowed_roles={AIRole.USER, AIRole.BUSINESS_OWNER, AIRole.OPERATOR, AIRole.ADMIN},
required_permissions=["receipts_payments.write"],
category="financial",
requires_approval=True,
risk_level="high",
is_readonly=False,
))
# اضافه کردن get_sales_report
def get_sales_report_wrapper(db, business_id, user_id, **kwargs):
"""Wrapper برای گزارش فروش"""
return get_sales_by_product_report(
db=db,
business_id=business_id,
fiscal_year_id=kwargs.get("fiscal_year_id"),
currency_id=kwargs.get("currency_id"),
date_from=kwargs.get("from_date"),
date_to=kwargs.get("to_date"),
product_ids=[kwargs.get("product_id")] if kwargs.get("product_id") else None,
category_ids=[kwargs.get("category_id")] if kwargs.get("category_id") else None,
warehouse_ids=None,
include_zero_sales=kwargs.get("include_zero_sales", False),
search=kwargs.get("search"),
skip=kwargs.get("skip", 0),
take=kwargs.get("take", 50)
)
self.register(AIFunction(
name="get_sales_report",
description="گزارش فروش بر اساس تاریخ، محصول، مشتری. شناسه کسب‌وکار به صورت خودکار از جلسه گفت‌وگو گرفته می‌شود.",
parameters_schema={
"type": "object",
"properties": {
"from_date": {"type": "string", "format": "date", "description": "تاریخ شروع"},
"to_date": {"type": "string", "format": "date", "description": "تاریخ پایان"},
"product_id": {"type": "integer", "description": "فیلتر بر اساس محصول (اختیاری)"},
"person_id": {"type": "integer", "description": "فیلتر بر اساس مشتری (اختیاری)"},
"category_id": {"type": "integer", "description": "فیلتر بر اساس دسته‌بندی (اختیاری)"},
"fiscal_year_id": {"type": "integer", "description": "شناسه سال مالی (اختیاری)"}
},
"required": ["from_date", "to_date"]
},
handler=self._create_handler(get_sales_report_wrapper),
allowed_roles={AIRole.USER, AIRole.BUSINESS_OWNER, AIRole.OPERATOR, AIRole.ADMIN},
required_permissions=["reports.read"],
category="financial"
))
# اضافه کردن get_purchase_report
def get_purchase_report_wrapper(db, business_id, user_id, **kwargs):
"""Wrapper برای گزارش خرید"""
from app.services.product_service import get_sales_by_product_report
from app.services.invoice_service import INVOICE_PURCHASE
from adapters.db.models.document import Document
from adapters.db.models.invoice_item_line import InvoiceItemLine
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import and_, or_
# تبدیل تاریخ‌ها
date_from_obj = None
date_to_obj = None
if kwargs.get("from_date"):
try:
date_from_obj = datetime.strptime(kwargs["from_date"], '%Y-%m-%d').date()
except ValueError:
pass
if kwargs.get("to_date"):
try:
date_to_obj = datetime.strptime(kwargs["to_date"], '%Y-%m-%d').date()
except ValueError:
pass
# دریافت فاکتورهای خرید در بازه زمانی
purchase_invoice_query = db.query(Document).filter(
and_(
Document.business_id == business_id,
Document.document_type == INVOICE_PURCHASE,
Document.is_proforma == False,
)
)
if date_from_obj:
purchase_invoice_query = purchase_invoice_query.filter(Document.document_date >= date_from_obj)
if date_to_obj:
purchase_invoice_query = purchase_invoice_query.filter(Document.document_date <= date_to_obj)
if kwargs.get("fiscal_year_id"):
purchase_invoice_query = purchase_invoice_query.filter(Document.fiscal_year_id == kwargs["fiscal_year_id"])
if kwargs.get("currency_id"):
purchase_invoice_query = purchase_invoice_query.filter(Document.currency_id == kwargs["currency_id"])
purchase_invoices = purchase_invoice_query.all()
invoice_ids = [inv.id for inv in purchase_invoices]
if not invoice_ids:
return {
'items': [],
'summary': {'total_count': 0, 'total_quantity': 0.0, 'total_amount': 0.0},
'pagination': {'total': 0, 'page': 1, 'per_page': 50, 'total_pages': 0, 'has_next': False, 'has_prev': False}
}
# دریافت خطوط فاکتور خرید
purchase_lines = db.query(InvoiceItemLine).filter(
InvoiceItemLine.document_id.in_(invoice_ids)
).all()
# گروه‌بندی خطوط بر اساس product_id
product_purchases = {}
product_ids_with_purchases = set()
for line in purchase_lines:
if not line.product_id:
continue
product_ids_with_purchases.add(line.product_id)
if line.product_id not in product_purchases:
product_purchases[line.product_id] = {
'total_quantity': Decimal(0),
'total_amount': Decimal(0),
'last_purchase_date': None,
}
qty = Decimal(str(line.quantity or 0))
line_total = Decimal(0)
extra_info = line.extra_info or {}
if 'line_total' in extra_info and extra_info['line_total'] is not None:
line_total = Decimal(str(extra_info['line_total']))
else:
unit_price = Decimal(str(extra_info.get('unit_price', 0) or 0))
line_discount = Decimal(str(extra_info.get('line_discount', 0) or 0))
tax_amount = Decimal(str(extra_info.get('tax_amount', 0) or 0))
if unit_price > 0 and qty > 0:
line_total = (unit_price * qty) - line_discount + tax_amount
product_purchases[line.product_id]['total_quantity'] += qty
product_purchases[line.product_id]['total_amount'] += line_total
# ساخت نتایج
items = []
for product_id, purchase_data in product_purchases.items():
items.append({
'product_id': product_id,
'total_quantity': float(purchase_data['total_quantity']),
'total_amount': float(purchase_data['total_amount']),
'last_purchase_date': purchase_data['last_purchase_date'].isoformat() if purchase_data['last_purchase_date'] else None
})
# Pagination
skip = kwargs.get("skip", 0)
take = kwargs.get("take", 50)
total = len(items)
paginated_items = items[skip:skip + take]
total_pages = (total + take - 1) // take if take > 0 else 0
current_page = (skip // take) + 1 if take > 0 else 1
total_quantity_sum = sum(item.get('total_quantity', 0) for item in items)
total_amount_sum = sum(item.get('total_amount', 0) for item in items)
return {
'items': paginated_items,
'summary': {
'total_count': total,
'total_quantity': float(total_quantity_sum),
'total_amount': float(total_amount_sum),
},
'pagination': {
'total': total,
'page': current_page,
'per_page': take,
'total_pages': total_pages,
'has_next': current_page < total_pages,
'has_prev': current_page > 1,
}
}
self.register(AIFunction(
name="get_purchase_report",
description="گزارش خرید بر اساس تاریخ، محصول، تامین‌کننده. شناسه کسب‌وکار به صورت خودکار از جلسه گفت‌وگو گرفته می‌شود.",
parameters_schema={
"type": "object",
"properties": {
"from_date": {"type": "string", "format": "date", "description": "تاریخ شروع"},
"to_date": {"type": "string", "format": "date", "description": "تاریخ پایان"},
"product_id": {"type": "integer", "description": "فیلتر بر اساس محصول (اختیاری)"},
"person_id": {"type": "integer", "description": "فیلتر بر اساس تامین‌کننده (اختیاری)"},
"category_id": {"type": "integer", "description": "فیلتر بر اساس دسته‌بندی (اختیاری)"},
"fiscal_year_id": {"type": "integer", "description": "شناسه سال مالی (اختیاری)"}
},
"required": ["from_date", "to_date"]
},
handler=self._create_handler(get_purchase_report_wrapper),
allowed_roles={AIRole.USER, AIRole.BUSINESS_OWNER, AIRole.OPERATOR, AIRole.ADMIN},
required_permissions=["reports.read"],
category="financial"
))
# اضافه کردن get_inventory_valuation
def get_inventory_valuation_wrapper(db, business_id, user_id, **kwargs):
"""Wrapper برای محاسبه ارزش موجودی"""
from app.services.warehouse_service import get_warehouse_stock_report
from adapters.db.models.product import Product
from decimal import Decimal
# دریافت موجودی
query = {
"product_ids": [kwargs.get("product_id")] if kwargs.get("product_id") else [],
"warehouse_ids": [kwargs.get("warehouse_id")] if kwargs.get("warehouse_id") else [],
"as_of_date": kwargs.get("as_of_date"),
"include_zero": kwargs.get("include_zero", False)
}
stock_report = get_warehouse_stock_report(db, business_id, query)
# محاسبه ارزش موجودی
total_valuation = Decimal(0)
items = []
for item in stock_report.get("items", []):
product_id = item.get("product_id")
quantity = Decimal(str(item.get("quantity", 0)))
# دریافت محصول
product = db.query(Product).filter(Product.id == product_id).first()
if not product:
continue
# قیمت تمام شده یا قیمت فروش
cost_price = Decimal(str(product.cost_price or 0))
if cost_price == 0:
cost_price = Decimal(str(product.sale_price or 0))
valuation = quantity * cost_price
total_valuation += valuation
items.append({
"product_id": product_id,
"product_name": product.name,
"quantity": float(quantity),
"cost_price": float(cost_price),
"valuation": float(valuation)
})
return {
"items": items,
"total_valuation": float(total_valuation),
"currency_id": stock_report.get("currency_id"),
"as_of_date": kwargs.get("as_of_date")
}
self.register(AIFunction(
name="get_inventory_valuation",
description="محاسبه ارزش موجودی محصولات در انبار بر اساس قیمت تمام شده. شناسه کسب‌وکار به صورت خودکار از جلسه گفت‌وگو گرفته می‌شود.",
parameters_schema={
"type": "object",
"properties": {
"product_id": {"type": "integer", "description": "شناسه محصول (اختیاری - اگر مشخص نشود، تمام محصولات)"},
"warehouse_id": {"type": "integer", "description": "شناسه انبار (اختیاری)"},
"as_of_date": {"type": "string", "format": "date", "description": "تاریخ محاسبه (اختیاری)"},
"include_zero": {"type": "boolean", "description": "نمایش محصولات با موجودی صفر (اختیاری)"}
},
"required": []
},
handler=self._create_handler(get_inventory_valuation_wrapper),
allowed_roles={AIRole.USER, AIRole.BUSINESS_OWNER, AIRole.OPERATOR, AIRole.ADMIN},
required_permissions=["inventory.read"],
category="financial"
))
# اضافه کردن get_cash_flow
def get_cash_flow_wrapper(db, business_id, user_id, **kwargs):
"""Wrapper برای گزارش گردش نقدی"""
from app.services.receipt_payment_service import list_receipts_payments
from decimal import Decimal
# دریافت دریافت‌ها و پرداخت‌ها
query_receipts = {
"document_type": "receipt",
"from_date": kwargs.get("from_date"),
"to_date": kwargs.get("to_date"),
"fiscal_year_id": kwargs.get("fiscal_year_id"),
"take": 100,
"skip": 0
}
query_payments = {
"document_type": "payment",
"from_date": kwargs.get("from_date"),
"to_date": kwargs.get("to_date"),
"fiscal_year_id": kwargs.get("fiscal_year_id"),
"take": 100,
"skip": 0
}
receipts_result = list_receipts_payments(db, business_id, query_receipts)
payments_result = list_receipts_payments(db, business_id, query_payments)
# محاسبه مجموع دریافت‌ها و پرداخت‌ها
total_receipts = Decimal(0)
total_payments = Decimal(0)
for item in receipts_result.get("items", []):
# محاسبه از account_lines یا person_lines
extra_info = item.get("extra_info", {})
total_receipts += Decimal(str(extra_info.get("total_amount", 0) or 0))
for item in payments_result.get("items", []):
extra_info = item.get("extra_info", {})
total_payments += Decimal(str(extra_info.get("total_amount", 0) or 0))
net_cash_flow = total_receipts - total_payments
return {
"period": {
"from_date": kwargs.get("from_date"),
"to_date": kwargs.get("to_date")
},
"total_receipts": float(total_receipts),
"total_payments": float(total_payments),
"net_cash_flow": float(net_cash_flow),
"receipts_count": receipts_result.get("pagination", {}).get("total", 0),
"payments_count": payments_result.get("pagination", {}).get("total", 0)
}
self.register(AIFunction(
name="get_cash_flow",
description="گزارش گردش نقدی شامل دریافت‌ها، پرداخت‌ها و خالص گردش نقدی. شناسه کسب‌وکار به صورت خودکار از جلسه گفت‌وگو گرفته می‌شود.",
parameters_schema={
"type": "object",
"properties": {
"from_date": {"type": "string", "format": "date", "description": "تاریخ شروع"},
"to_date": {"type": "string", "format": "date", "description": "تاریخ پایان"},
"fiscal_year_id": {"type": "integer", "description": "شناسه سال مالی (اختیاری)"}
},
"required": ["from_date", "to_date"]
},
handler=self._create_handler(get_cash_flow_wrapper),
allowed_roles={AIRole.USER, AIRole.BUSINESS_OWNER, AIRole.OPERATOR, AIRole.ADMIN},
required_permissions=["reports.read"],
category="financial"
))
def _register_crm_functions(self):
"""ثبت function های مربوط به CRM"""
from sqlalchemy import func as sql_func, and_, or_
from adapters.db.models.crm import Lead, Deal, CrmActivity, CrmProcessDefinition, CrmProcessStage
from adapters.db.models.person import Person
def _lead_to_dict_simple(lead: Lead) -> Dict[str, Any]:
d = {
"id": lead.id,
"stage_name": lead.stage.name if lead.stage else None,
"source_code": lead.source_code,
"name": lead.name,
"company_name": lead.company_name,
"mobile": lead.mobile,
"email": lead.email,
"description": lead.description,
"person_id": lead.person_id,
"converted_at": lead.converted_at.isoformat() if lead.converted_at else None,
"created_at": lead.created_at.isoformat() if lead.created_at else None,
}
return d
def _deal_to_dict_simple(deal: Deal) -> Dict[str, Any]:
d = {
"id": deal.id,
"person_name": deal.person.alias_name if deal.person else None,
"stage_name": deal.stage.name if deal.stage else None,
"title": deal.title,
"amount": float(deal.amount),
"probability_percent": deal.probability_percent,
"expected_close_date": deal.expected_close_date.isoformat() if deal.expected_close_date else None,
"closed_at": deal.closed_at.isoformat() if deal.closed_at else None,
"created_at": deal.created_at.isoformat() if deal.created_at else None,
}
return d
def _activity_to_dict_simple(a: CrmActivity) -> Dict[str, Any]:
d = {
"id": a.id,
"activity_type": a.activity_type,
"subject": a.subject,
"description": a.description,
"activity_date": a.activity_date.isoformat() if a.activity_date else None,
"deal_id": a.deal_id,
"created_at": a.created_at.isoformat() if a.created_at else None,
}
return d
def search_leads_wrapper(db, business_id, user_id, **kwargs):
q = db.query(Lead).filter(Lead.business_id == business_id)
if kwargs.get("process_definition_id"):
q = q.filter(Lead.process_definition_id == kwargs["process_definition_id"])
if kwargs.get("stage_id"):
q = q.filter(Lead.stage_id == kwargs["stage_id"])
if kwargs.get("assigned_to_user_id") is not None:
q = q.filter(Lead.assigned_to_user_id == kwargs["assigned_to_user_id"])
search = kwargs.get("search", "").strip()
if search:
term = f"%{search}%"
q = q.filter(
or_(
Lead.name.ilike(term),
Lead.company_name.ilike(term),
Lead.mobile.ilike(term),
Lead.email.ilike(term),
)
)
limit = kwargs.get("limit", 20)
skip = kwargs.get("skip", 0)
total = q.count()
items = q.order_by(Lead.created_at.desc()).offset(skip).limit(limit).all()
return {"items": [_lead_to_dict_simple(lead) for lead in items], "total": total}
def get_lead_details_wrapper(db, business_id, user_id, lead_id, **kwargs):
lead = db.query(Lead).filter(
and_(Lead.id == lead_id, Lead.business_id == business_id)
).first()
if not lead:
raise ValueError(f"Lead {lead_id} not found")
return _lead_to_dict_simple(lead)
def search_deals_wrapper(db, business_id, user_id, **kwargs):
q = db.query(Deal).filter(Deal.business_id == business_id)
if kwargs.get("process_definition_id"):
q = q.filter(Deal.process_definition_id == kwargs["process_definition_id"])
if kwargs.get("stage_id"):
q = q.filter(Deal.stage_id == kwargs["stage_id"])
if kwargs.get("person_id"):
q = q.filter(Deal.person_id == kwargs["person_id"])
if kwargs.get("assigned_to_user_id") is not None:
q = q.filter(Deal.assigned_to_user_id == kwargs["assigned_to_user_id"])
search = kwargs.get("search", "").strip()
if search:
term = f"%{search}%"
q = q.join(Deal.person).filter(
or_(Deal.title.ilike(term), Person.alias_name.ilike(term))
)
limit = kwargs.get("limit", 20)
skip = kwargs.get("skip", 0)
total = q.count()
items = q.order_by(Deal.updated_at.desc()).offset(skip).limit(limit).all()
return {"items": [_deal_to_dict_simple(dl) for dl in items], "total": total}
def get_deal_details_wrapper(db, business_id, user_id, deal_id, **kwargs):
deal = db.query(Deal).filter(
and_(Deal.id == deal_id, Deal.business_id == business_id)
).first()
if not deal:
raise ValueError(f"Deal {deal_id} not found")
return _deal_to_dict_simple(deal)
def search_activities_wrapper(db, business_id, user_id, **kwargs):
q = db.query(CrmActivity).filter(CrmActivity.business_id == business_id)
if kwargs.get("person_id"):
q = q.filter(CrmActivity.person_id == kwargs["person_id"])
if kwargs.get("deal_id"):
q = q.filter(CrmActivity.deal_id == kwargs["deal_id"])
if kwargs.get("activity_type"):
q = q.filter(CrmActivity.activity_type == kwargs["activity_type"])
limit = kwargs.get("limit", 20)
skip = kwargs.get("skip", 0)
total = q.count()
items = q.order_by(CrmActivity.activity_date.desc()).offset(skip).limit(limit).all()
return {"items": [_activity_to_dict_simple(a) for a in items], "total": total}
def get_crm_summary_wrapper(db, business_id, user_id, **kwargs):
total_leads = db.query(Lead).filter(Lead.business_id == business_id).count()
converted_leads = db.query(Lead).filter(
Lead.business_id == business_id, Lead.person_id.isnot(None)
).count()
total_deals = db.query(Deal).filter(Deal.business_id == business_id).count()
deals_amount = (
db.query(sql_func.coalesce(sql_func.sum(Deal.amount), 0))
.filter(Deal.business_id == business_id)
.scalar() or 0
)
closed_deals = db.query(Deal).filter(
Deal.business_id == business_id, Deal.closed_at.isnot(None)
).count()
conversion_rate = (converted_leads / total_leads * 100) if total_leads else 0
return {
"total_leads": total_leads,
"converted_leads": converted_leads,
"conversion_rate": round(conversion_rate, 1),
"total_deals": total_deals,
"closed_deals": closed_deals,
"total_deals_amount": float(deals_amount),
}
def get_pipeline_report_wrapper(db, business_id, user_id, **kwargs):
process_def_id = kwargs.get("process_definition_id")
q = db.query(
CrmProcessStage.id,
CrmProcessStage.name,
CrmProcessStage.order_index,
sql_func.count(Deal.id).label("deal_count"),
sql_func.coalesce(sql_func.sum(Deal.amount), 0).label("total_amount"),
).outerjoin(
Deal, and_(Deal.stage_id == CrmProcessStage.id, Deal.business_id == business_id)
)
q = q.join(CrmProcessDefinition, CrmProcessDefinition.id == CrmProcessStage.process_definition_id)
q = q.filter(
CrmProcessDefinition.business_id == business_id,
CrmProcessDefinition.process_type == "sales_pipeline",
)
if process_def_id:
q = q.filter(CrmProcessDefinition.id == process_def_id)
q = q.group_by(CrmProcessStage.id, CrmProcessStage.name, CrmProcessStage.order_index)
q = q.order_by(CrmProcessStage.order_index)
rows = q.all()
return [
{"stage_id": r.id, "stage_name": r.name, "order_index": r.order_index, "deal_count": r.deal_count, "total_amount": float(r.total_amount or 0)}
for r in rows
]
def get_lead_funnel_report_wrapper(db, business_id, user_id, **kwargs):
process_def_id = kwargs.get("process_definition_id")
q = db.query(
CrmProcessStage.id,
CrmProcessStage.name,
CrmProcessStage.order_index,
sql_func.count(Lead.id).label("lead_count"),
).outerjoin(
Lead, and_(Lead.stage_id == CrmProcessStage.id, Lead.business_id == business_id)
)
q = q.join(CrmProcessDefinition, CrmProcessDefinition.id == CrmProcessStage.process_definition_id)
q = q.filter(
CrmProcessDefinition.business_id == business_id,
CrmProcessDefinition.process_type == "lead_funnel",
)
if process_def_id:
q = q.filter(CrmProcessDefinition.id == process_def_id)
q = q.group_by(CrmProcessStage.id, CrmProcessStage.name, CrmProcessStage.order_index)
q = q.order_by(CrmProcessStage.order_index)
rows = q.all()
return [
{"stage_id": r.id, "stage_name": r.name, "order_index": r.order_index, "lead_count": r.lead_count}
for r in rows
]
self.register(AIFunction(
name="search_leads",
description="جستجو در سرنخ‌های CRM بر اساس مرحله، منبع، مسئول و متن جستجو. شناسه کسب‌وکار به صورت خودکار از جلسه گفت‌وگو گرفته می‌شود.",
parameters_schema={
"type": "object",
"properties": {
"process_definition_id": {"type": "integer", "description": "شناسه فرایند (اختیاری)"},
"stage_id": {"type": "integer", "description": "شناسه مرحله (اختیاری)"},
"assigned_to_user_id": {"type": "integer", "description": "شناسه مسئول (اختیاری)"},
"search": {"type": "string", "description": "جستجو در نام، شرکت، موبایل، ایمیل (اختیاری)"},
"limit": {"type": "integer", "description": "تعداد نتایج (پیش‌فرض: 20)"},
"skip": {"type": "integer", "description": "ردیف شروع (پیش‌فرض: 0)"},
},
"required": [],
},
handler=self._create_handler(search_leads_wrapper),
allowed_roles={AIRole.USER, AIRole.BUSINESS_OWNER, AIRole.OPERATOR, AIRole.ADMIN},
required_permissions=["crm.view"],
category="crm",
))
self.register(AIFunction(
name="get_lead_details",
description="دریافت جزئیات کامل یک سرنخ. شناسه کسب‌وکار به صورت خودکار از جلسه گفت‌وگو گرفته می‌شود.",
parameters_schema={
"type": "object",
"properties": {"lead_id": {"type": "integer", "description": "شناسه سرنخ"}},
"required": ["lead_id"],
},
handler=self._create_handler(get_lead_details_wrapper),
allowed_roles={AIRole.USER, AIRole.BUSINESS_OWNER, AIRole.OPERATOR, AIRole.ADMIN},
required_permissions=["crm.view"],
category="crm",
))
self.register(AIFunction(
name="search_deals",
description="جستجو در فرصت‌های فروش CRM بر اساس مرحله، مشتری، مسئول و متن جستجو. شناسه کسب‌وکار به صورت خودکار از جلسه گفت‌وگو گرفته می‌شود.",
parameters_schema={
"type": "object",
"properties": {
"process_definition_id": {"type": "integer", "description": "شناسه فرایند (اختیاری)"},
"stage_id": {"type": "integer", "description": "شناسه مرحله (اختیاری)"},
"person_id": {"type": "integer", "description": "شناسه مشتری (اختیاری)"},
"assigned_to_user_id": {"type": "integer", "description": "شناسه مسئول (اختیاری)"},
"search": {"type": "string", "description": "جستجو در عنوان یا نام مشتری (اختیاری)"},
"limit": {"type": "integer", "description": "تعداد نتایج (پیش‌فرض: 20)"},
"skip": {"type": "integer", "description": "ردیف شروع (پیش‌فرض: 0)"},
},
"required": [],
},
handler=self._create_handler(search_deals_wrapper),
allowed_roles={AIRole.USER, AIRole.BUSINESS_OWNER, AIRole.OPERATOR, AIRole.ADMIN},
required_permissions=["crm.view"],
category="crm",
))
self.register(AIFunction(
name="get_deal_details",
description="دریافت جزئیات کامل یک فرصت فروش. شناسه کسب‌وکار به صورت خودکار از جلسه گفت‌وگو گرفته می‌شود.",
parameters_schema={
"type": "object",
"properties": {"deal_id": {"type": "integer", "description": "شناسه فرصت فروش"}},
"required": ["deal_id"],
},
handler=self._create_handler(get_deal_details_wrapper),
allowed_roles={AIRole.USER, AIRole.BUSINESS_OWNER, AIRole.OPERATOR, AIRole.ADMIN},
required_permissions=["crm.view"],
category="crm",
))
self.register(AIFunction(
name="search_activities",
description="جستجو در فعالیت‌های CRM (تماس، ایمیل، جلسه، یادداشت) بر اساس شخص، فرصت و نوع. شناسه کسب‌وکار به صورت خودکار از جلسه گفت‌وگو گرفته می‌شود.",
parameters_schema={
"type": "object",
"properties": {
"person_id": {"type": "integer", "description": "شناسه شخص (اختیاری)"},
"deal_id": {"type": "integer", "description": "شناسه فرصت فروش (اختیاری)"},
"activity_type": {"type": "string", "enum": ["call", "email", "meeting", "note"], "description": "نوع فعالیت (اختیاری)"},
"limit": {"type": "integer", "description": "تعداد نتایج (پیش‌فرض: 20)"},
"skip": {"type": "integer", "description": "ردیف شروع (پیش‌فرض: 0)"},
},
"required": [],
},
handler=self._create_handler(search_activities_wrapper),
allowed_roles={AIRole.USER, AIRole.BUSINESS_OWNER, AIRole.OPERATOR, AIRole.ADMIN},
required_permissions=["crm.view"],
category="crm",
))
self.register(AIFunction(
name="get_crm_summary",
description="دریافت خلاصه CRM شامل تعداد سرنخ‌ها، فرصت‌های فروش، نرخ تبدیل و مبلغ کل. شناسه کسب‌وکار به صورت خودکار از جلسه گفت‌وگو گرفته می‌شود.",
parameters_schema={"type": "object", "properties": {}, "required": []},
handler=self._create_handler(get_crm_summary_wrapper),
allowed_roles={AIRole.USER, AIRole.BUSINESS_OWNER, AIRole.OPERATOR, AIRole.ADMIN},
required_permissions=["crm.view"],
category="crm",
))
self.register(AIFunction(
name="get_pipeline_report",
description="گزارش پایپلاین فروش: تعداد و مبلغ فرصت‌ها به تفکیک مرحله. شناسه کسب‌وکار به صورت خودکار از جلسه گفت‌وگو گرفته می‌شود.",
parameters_schema={
"type": "object",
"properties": {"process_definition_id": {"type": "integer", "description": "شناسه فرایند پایپلاین (اختیاری)"}},
"required": [],
},
handler=self._create_handler(get_pipeline_report_wrapper),
allowed_roles={AIRole.USER, AIRole.BUSINESS_OWNER, AIRole.OPERATOR, AIRole.ADMIN},
required_permissions=["crm.view"],
category="crm",
))
self.register(AIFunction(
name="get_lead_funnel_report",
description="گزارش قیف سرنخ: تعداد سرنخ‌ها به تفکیک مرحله. شناسه کسب‌وکار به صورت خودکار از جلسه گفت‌وگو گرفته می‌شود.",
parameters_schema={
"type": "object",
"properties": {"process_definition_id": {"type": "integer", "description": "شناسه فرایند (اختیاری)"}},
"required": [],
},
handler=self._create_handler(get_lead_funnel_report_wrapper),
allowed_roles={AIRole.USER, AIRole.BUSINESS_OWNER, AIRole.OPERATOR, AIRole.ADMIN},
required_permissions=["crm.view"],
category="crm",
))
def _register_operator_functions(self):
"""ثبت function های مخصوص اپراتورهای پشتیبانی"""
# این function ها بعداً اضافه می‌شوند
pass
def _register_admin_functions(self):
"""ثبت function های مخصوص مدیر سیستم"""
# این function ها بعداً اضافه می‌شوند
pass
def _register_business_owner_functions(self):
"""ثبت function های مخصوص مالک کسب‌وکار"""
# این function ها بعداً اضافه می‌شوند
pass
def _register_connector_functions(self):
"""فراخوانی کانکتورهای HTTP تعریف‌شده توسط کسب‌وکار."""
from app.services.ai.ai_connector_service import invoke_connector_handler
self.register(
AIFunction(
name="invoke_business_connector",
description=(
"فراخوانی یک کانکتور HTTP خارجی که برای این کسب‌وکار تعریف شده "
"(لیست نام‌ها در system prompt). برای پارامترهای URL از query_params استفاده کن."
),
parameters_schema={
"type": "object",
"properties": {
"connector_name": {
"type": "string",
"description": "نام یکتا کانکتور (slug)",
},
"query_params": {
"type": "object",
"description": "پارامترهای query یا جایگزین {{key}} در URL",
},
"body": {
"type": "object",
"description": "بدنه JSON برای POST",
},
},
"required": ["connector_name"],
},
handler=invoke_connector_handler,
allowed_roles={AIRole.USER, AIRole.BUSINESS_OWNER, AIRole.ADMIN},
required_permissions=["settings.view"],
business_context_required=True,
category="integration",
)
)
def _create_handler(self, service_func: Callable) -> Callable:
"""
ایجاد wrapper برای service function ها
این wrapper context (db, user_context) را اضافه می‌کند
و business_id را از session inject می‌کند (امنیت)
دو قرارداد پشتیبانی می‌شود:
- سبک قدیمی: ``fn(db, business_id, user_id, **kwargs)``
- سبک (args, context): ``fn(args, context)`` — مثلاً create_session_plan
"""
return wrap_registry_service_func(service_func)
def register(self, func: AIFunction):
"""ثبت function جدید و اتصال Metadata Manifest."""
from app.services.ai.ai_tool_index import bind_manifest
bound = bind_manifest(func)
self._functions[bound.name] = bound
def get_function(self, name: str) -> Optional[AIFunction]:
"""دریافت AIFunction با نام — None اگر وجود نداشته باشد."""
return self._functions.get(name)
def iter_functions(self) -> List[AIFunction]:
return list(self._functions.values())
def function_names(self) -> List[str]:
return list(self._functions.keys())
def _detect_user_role(
self,
user_context: AuthContext,
business_id: Optional[int] = None
) -> Set[AIRole]:
"""
تشخیص نقش کاربر بر اساس دسترسی‌ها
"""
roles = set()
# بررسی superadmin
if user_context.is_superadmin():
roles.add(AIRole.ADMIN)
# SuperAdmin به همه function ها دسترسی دارد
roles.add(AIRole.USER)
roles.add(AIRole.OPERATOR)
roles.add(AIRole.BUSINESS_OWNER)
return roles
# بررسی اپراتور پشتیبانی
if user_context.can_access_support_operator():
roles.add(AIRole.OPERATOR)
# بررسی مالک کسب‌وکار
target_business_id = business_id or user_context.business_id
if target_business_id and user_context.is_business_owner(target_business_id):
roles.add(AIRole.BUSINESS_OWNER)
# کاربر عادی (اگر business_id دارد)
if target_business_id and user_context.can_access_business(target_business_id):
roles.add(AIRole.USER)
return roles
def _authorized_functions(
self,
context: Dict[str, Any],
filter_by_category: Optional[str] = None,
) -> List["AIFunction"]:
"""Functionهای مجاز tenant/role/permission — بدون ساخت JSON Schema."""
user_context: AuthContext = context["user_context"]
business_id = context.get("business_id")
user_roles = self._detect_user_role(user_context, business_id)
from app.services.ai.ai_permission_policy import catalog_permission_allows
out: List[AIFunction] = []
for func in self._functions.values():
if not (func.allowed_roles & user_roles):
continue
if not catalog_permission_allows(func, user_context, business_id):
continue
if func.business_context_required and not business_id:
continue
if filter_by_category and func.category != filter_by_category:
continue
out.append(func)
return out
def get_authorized_function_names(
self,
context: Dict[str, Any],
filter_by_category: Optional[str] = None,
) -> List[str]:
return [func.name for func in self._authorized_functions(context, filter_by_category)]
def build_openai_tool_definition(self, name: str) -> Optional[Dict[str, Any]]:
func = self._functions.get(name)
if func is None:
return None
return {
"type": "function",
"function": {
"name": func.name,
"description": func.description,
"parameters": func.parameters_schema,
},
}
def schema_version_for(self, name: str) -> str:
"""Cache key: declared schema_version + hash of description/parameters.
Authors should bump `schema_version` when the contract changes.
The content hash still invalidates the cache if the JSON drifts
without a manual bump.
"""
func = self._functions.get(name)
if func is None:
return "0"
declared = str(getattr(func, "schema_version", None) or "1")
payload = json.dumps(
{
"description": getattr(func, "description", None),
"parameters": getattr(func, "parameters_schema", None),
},
sort_keys=True,
ensure_ascii=False,
default=str,
)
digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()[:12]
return f"{declared}.{digest}"
def get_function_definitions(
self,
context: Dict[str, Any],
filter_by_category: Optional[str] = None
) -> List[Dict[str, Any]]:
"""
دریافت لیست function definitions برای OpenAI
فقط function هایی که کاربر دسترسی دارد را برمی‌گرداند
"""
definitions = []
for func in self._authorized_functions(context, filter_by_category):
built = self.build_openai_tool_definition(func.name)
if built:
definitions.append(built)
return definitions
def call_function(
self,
name: str,
arguments: Dict[str, Any],
context: Dict[str, Any]
) -> Any:
"""
فراخوانی یک function با validation امنیتی
"""
if name not in self._functions:
raise ValueError(f"Function '{name}' not found in registry")
func = self._functions[name]
user_context: AuthContext = context["user_context"]
# دریافت business_id از session (اولویت) یا context
session_business_id = context.get("session_business_id")
context_business_id = context.get("business_id")
effective_business_id = session_business_id or context_business_id
# امنیت: اگر AI یک business_id دیگر در arguments بدهد، validation می‌کنیم
if "business_id" in arguments:
provided_business_id = arguments.get("business_id")
if provided_business_id and provided_business_id != effective_business_id:
# بررسی دسترسی کاربر به business_id ارائه شده
if not user_context.can_access_business(provided_business_id):
raise PermissionError(
f"User does not have access to business {provided_business_id}. "
f"Session business_id is {effective_business_id}"
)
# اگر دسترسی دارد اما متفاوت است، warning می‌دهیم
import logging
logger = logging.getLogger(__name__)
logger.warning(
f"Function {name}: AI provided business_id {provided_business_id} "
f"but session has {effective_business_id}. "
f"Handler will use session business_id for security."
)
# بررسی نقش
user_roles = self._detect_user_role(user_context, effective_business_id)
if not (func.allowed_roles & user_roles):
raise PermissionError(
f"User role {user_roles} does not have access to function {name}. "
f"Required roles: {func.allowed_roles}"
)
from app.services.ai.ai_permission_policy import catalog_permission_allows
if not catalog_permission_allows(func, user_context, effective_business_id):
raise PermissionError(f"User does not have required permissions for {name}")
# بررسی business context
if func.business_context_required and not effective_business_id:
raise ValueError(f"Function {name} requires business context")
# فراخوانی handler (handler خودش business_id را از session inject می‌کند)
return func.handler(arguments, context)
# Singleton instance
registry = AIFunctionRegistry()