diff --git a/huf/ai/tools/_registry.py b/huf/ai/tools/_registry.py index 90fc539b..51b722b3 100644 --- a/huf/ai/tools/_registry.py +++ b/huf/ai/tools/_registry.py @@ -20,10 +20,33 @@ def _p(name, type="string", required=False, description=""): } +def _action(choices): + return _p("action", required=True, description=f"Action to perform. One of: {choices}") + + # --------------------------------------------------------------------------- -# Communication Tools +# Communication & Developer Tools +# Shipped in develop (PR #273). Not refactored — kept exactly as-is. # --------------------------------------------------------------------------- +RECIPIENT_TOOLS = [ + { + "tool_name": "get_integration_recipient", + "description": ( + "Look up a named recipient's service-specific ID from Integration Settings. " + "Use this before sending a message to resolve a human name (e.g. 'John Doe', 'Sales Team') " + "to the correct Telegram Chat ID, Slack User/Channel ID, Discord Channel ID, etc. " + "Call this tool first, then pass the returned recipient_id to the relevant send tool." + ), + "function_path": "huf.ai.tools.recipient.handle_get_recipient", + "category": "Communication Tools", + "parameters": [ + _p("service", required=True, description="The service name, e.g. 'telegram', 'slack', 'discord'"), + _p("recipient_name", required=True, description="Human-friendly recipient name as stored in Integration Settings, e.g. 'John Doe'"), + ], + }, +] + SLACK_TOOLS = [ { "tool_name": "slack_send_message", @@ -202,24 +225,194 @@ def _p(name, type="string", required=False, description=""): }, ] -RECIPIENT_TOOLS = [ - { - "tool_name": "get_integration_recipient", - "description": ( - "Look up a named recipient's service-specific ID from Integration Settings. " - "Use this before sending a message to resolve a human name (e.g. 'John Doe', 'Sales Team') " - "to the correct Telegram Chat ID, Slack User/Channel ID, Discord Channel ID, etc. " - "Call this tool first, then pass the returned recipient_id to the relevant send tool." - ), - "function_path": "huf.ai.tools.recipient.handle_get_recipient", - "category": "Communication Tools", - "parameters": [ - _p("service", required=True, description="The service name, e.g. 'telegram', 'slack', 'discord'"), - _p("recipient_name", required=True, description="Human-friendly recipient name as stored in Integration Settings, e.g. 'John Doe'"), - ], - }, -] +# --------------------------------------------------------------------------- +# Frappe App Tools (added in this branch — consolidated action-based) +# --------------------------------------------------------------------------- + +CRM_TOOLS = [{ + "tool_name": "frappe_crm", + "description": "Manage Frappe CRM (standalone app). Actions: list_leads (status, assigned_to, search, limit), get_lead (name), create_lead (first_name required; last_name, email, mobile_no, lead_owner, source, organization, notes), update_lead (name required; any field), list_deals (status, deal_owner, search, limit), get_deal (name), create_deal (organization; or lead to copy from), update_deal (name required; status, deal_value, probability, expected_closure_date), add_note (doctype, docname, content, title), add_task (title, reference_doctype, reference_docname; assigned_to, due_date, priority), list_contacts (search, limit).", + "function_path": "huf.ai.tools.crm.handle_action", + "category": "Frappe CRM Tools", + "parameters": [ + _action("list_leads|get_lead|create_lead|update_lead|list_deals|get_deal|create_deal|update_deal|add_note|add_task|list_contacts"), + _p("name", description="Document name/ID"), + _p("first_name", description="Lead first name"), + _p("last_name", description="Lead last name"), + _p("email", description="Email address"), + _p("mobile_no", description="Mobile number"), + _p("lead_owner", description="Assigned user email"), + _p("organization", description="Company/organization name"), + _p("status", description="Status filter or value to set"), + _p("search", description="Search query"), + _p("doctype", description="DocType for note/task (CRM Lead or CRM Deal)"), + _p("docname", description="Document name for note/task"), + _p("content", description="Note content"), + _p("title", description="Note or task title"), + _p("deal_value", type="number", description="Deal value amount"), + _p("probability", type="integer", description="Win probability 0-100"), + _p("limit", type="integer", description="Max results"), + ], +}] + +HELPDESK_TOOLS = [{ + "tool_name": "helpdesk", + "description": "Manage Frappe Helpdesk tickets. Actions: list_tickets (status, priority, team, search, limit), get_ticket (ticket_id — includes comments), create_ticket (subject required; description, customer, priority, type, team), update_ticket (ticket_id required; status, priority, team, description, assigned_to), add_comment (ticket_id, content), list_agents (limit), list_teams (limit), assign_ticket (ticket_id, agent_id).", + "function_path": "huf.ai.tools.helpdesk.handle_action", + "category": "Helpdesk Tools", + "parameters": [ + _action("list_tickets|get_ticket|create_ticket|update_ticket|add_comment|list_agents|list_teams|assign_ticket"), + _p("ticket_id", description="Ticket ID (HD-XXXXX)"), + _p("subject", description="Ticket subject"), + _p("description", description="Ticket description or updated text"), + _p("status", description="Status: Open, Replied, Resolved, Closed"), + _p("priority", description="Priority: Low, Medium, High, Urgent"), + _p("team", description="Team name"), + _p("customer", description="Customer name"), + _p("content", description="Comment text"), + _p("agent_id", description="Agent user ID for assignment"), + _p("search", description="Search query"), + _p("limit", type="integer", description="Max results"), + ], +}] + +RAVEN_TOOLS = [{ + "tool_name": "raven", + "description": "Interact with Frappe Raven internal messaging. Actions: send_message (channel_id or channel_name, text), get_messages (channel_id or channel_name, limit, before_message_id), list_channels (channel_type, limit), get_members (channel_id or channel_name), create_channel (channel_name, type, channel_description, members list), search_messages (query; channel_id or channel_name, limit).", + "function_path": "huf.ai.tools.raven.handle_action", + "category": "Raven Tools", + "parameters": [ + _action("send_message|get_messages|list_channels|get_members|create_channel|search_messages"), + _p("channel_id", description="Raven channel ID"), + _p("channel_name", description="Channel name (alternative to channel_id)"), + _p("text", description="Message text"), + _p("channel_type", description="Channel type filter: Public, Private, Open"), + _p("members", description="JSON list of user IDs (for create_channel)"), + _p("channel_description", description="Channel description"), + _p("query", description="Search text"), + _p("before_message_id", description="Pagination cursor"), + _p("limit", type="integer", description="Max results"), + ], +}] + +# --------------------------------------------------------------------------- +# ERPNext Tools (added in this branch — consolidated action-based) +# --------------------------------------------------------------------------- + +ERPNEXT_TOOLS = [{ + "tool_name": "erpnext", + "description": "Manage ERPNext transactions and accounting. Actions: list_sales_invoices (customer, status, from_date, to_date, limit), get_sales_invoice (name), create_sales_invoice (customer required, items list [{item_code,qty,rate}], company, posting_date), list_purchase_invoices (supplier, status, from_date, to_date, limit), get_purchase_invoice (name), list_payments (party_type, party, payment_type, from_date, to_date, limit), create_payment (payment_type required [Receive/Pay], party_type required, party required, paid_amount required; mode_of_payment, invoice_name, source_exchange_rate, target_exchange_rate), list_customers (search, customer_group, limit), get_customer (name), list_quotations (party_name, status, from_date, limit), create_quotation (quotation_to required [Customer/Lead], party_name required, items list, transaction_date, valid_till), list_rfqs (status, from_date, limit), get_ledger (account required, from_date, to_date, party_type, party, limit), create_journal_entry (voucher_type, posting_date, company, user_remark, accounts list [{account, debit_in_account_currency, credit_in_account_currency}]).", + "function_path": "huf.ai.tools.erpnext.handle_action", + "category": "ERPNext Tools", + "parameters": [ + _action("list_sales_invoices|get_sales_invoice|create_sales_invoice|list_purchase_invoices|get_purchase_invoice|list_payments|create_payment|list_customers|get_customer|list_quotations|create_quotation|list_rfqs|get_ledger|create_journal_entry"), + _p("name", description="Document name/ID"), + _p("customer", description="Customer name"), + _p("supplier", description="Supplier name"), + _p("party_type", description="Party type: Customer or Supplier"), + _p("party", description="Party name"), + _p("payment_type", description="Payment type: Receive, Pay, Internal Transfer"), + _p("paid_amount", type="number", description="Payment amount"), + _p("invoice_name", description="Invoice to link payment to"), + _p("mode_of_payment", description="Mode of payment"), + _p("source_exchange_rate", type="number", description="Source exchange rate (if different currency)"), + _p("target_exchange_rate", type="number", description="Target exchange rate (if different currency)"), + _p("account", description="Account name (for get_ledger)"), + _p("status", description="Document status filter"), + _p("from_date", description="Start date (YYYY-MM-DD)"), + _p("to_date", description="End date (YYYY-MM-DD)"), + _p("company", description="Company name (defaults to user default)"), + _p("posting_date", description="Posting date"), + _p("items", description="JSON list of items [{item_code, qty, rate}]"), + _p("accounts", description="JSON list of journal accounts [{account, debit_in_account_currency, credit_in_account_currency}]"), + _p("voucher_type", description="Journal voucher type"), + _p("user_remark", description="Journal entry remark"), + _p("quotation_to", description="Quotation for: Customer or Lead"), + _p("party_name", description="Customer/Lead name for quotation"), + _p("search", description="Search query"), + _p("limit", type="integer", description="Max results"), + ], +}] +ERPNEXT_CRM_TOOLS = [{ + "tool_name": "erpnext_crm", + "description": "Manage ERPNext built-in CRM (Lead and Opportunity doctypes — part of ERPNext, different from standalone Frappe CRM). Actions: list_leads (status, lead_owner, search, limit), get_lead (name), create_lead (lead_name required; company_name, email_id, mobile_no, lead_owner, type, industry, territory), update_lead (name required; status, lead_owner, email_id, territory), list_opportunities (status, party_name, from_date, limit), create_opportunity (opportunity_from required [Customer/Lead], party_name required; title, opportunity_type, opportunity_amount, sales_stage, probability, expected_closing), update_opportunity (name required; status, opportunity_amount, sales_stage, probability, expected_closing).", + "function_path": "huf.ai.tools.erpnext_crm.handle_action", + "category": "ERPNext CRM Tools", + "parameters": [ + _action("list_leads|get_lead|create_lead|update_lead|list_opportunities|create_opportunity|update_opportunity"), + _p("name", description="Document name/ID"), + _p("lead_name", description="Lead full name"), + _p("company_name", description="Company name"), + _p("email_id", description="Email address"), + _p("mobile_no", description="Mobile number"), + _p("lead_owner", description="Assigned user email"), + _p("status", description="Status filter or value to set"), + _p("territory", description="Territory"), + _p("industry", description="Industry"), + _p("opportunity_from", description="Opportunity from: Customer or Lead"), + _p("party_name", description="Customer or Lead name"), + _p("opportunity_amount", type="number", description="Opportunity value"), + _p("sales_stage", description="Sales stage"), + _p("probability", type="integer", description="Win probability 0-100"), + _p("expected_closing", description="Expected closing date (YYYY-MM-DD)"), + _p("search", description="Search query"), + _p("from_date", description="Filter from date"), + _p("limit", type="integer", description="Max results"), + ], +}] + +ERPNEXT_INVENTORY_TOOLS = [{ + "tool_name": "erpnext_inventory", + "description": "Manage ERPNext inventory, items, BOM and stock. Actions: list_items (search, item_group, is_stock_item, limit), get_item (name — item_code), item_prices (item_code, price_list, buying, selling), stock_balance (item_code, warehouse, as_of_date), stock_movements (item_code, warehouse, from_date, to_date, limit), list_stock_entries (stock_entry_type, from_date, to_date, limit), list_warehouses (company, limit), list_delivery_notes (customer, from_date, to_date, limit), list_purchase_receipts (supplier, from_date, to_date, limit), list_boms (item, is_active, is_default, limit), get_bom (name), create_bom (item required, quantity, items list [{item_code, qty, uom, rate}]).", + "function_path": "huf.ai.tools.erpnext_inventory.handle_action", + "category": "ERPNext Inventory", + "parameters": [ + _action("list_items|get_item|item_prices|stock_balance|stock_movements|list_stock_entries|list_warehouses|list_delivery_notes|list_purchase_receipts|list_boms|get_bom|create_bom"), + _p("name", description="Document name or item_code"), + _p("item_code", description="Item code"), + _p("item_group", description="Item group filter"), + _p("is_stock_item", type="integer", description="1 for stock items only, 0 for all"), + _p("price_list", description="Price list name"), + _p("warehouse", description="Warehouse name"), + _p("as_of_date", description="Stock balance as of date (YYYY-MM-DD)"), + _p("stock_entry_type", description="Material Issue, Material Receipt, Material Transfer, Manufacture"), + _p("customer", description="Customer name filter"), + _p("supplier", description="Supplier name filter"), + _p("item", description="Item code for BOM filter"), + _p("is_active", type="integer", description="1 for active BOMs"), + _p("is_default", type="integer", description="1 for default BOMs"), + _p("quantity", type="number", description="BOM quantity"), + _p("items", description="JSON list of BOM items [{item_code, qty, uom, rate}]"), + _p("from_date", description="Start date (YYYY-MM-DD)"), + _p("to_date", description="End date (YYYY-MM-DD)"), + _p("search", description="Search query"), + _p("company", description="Company name"), + _p("limit", type="integer", description="Max results"), + ], +}] + +ERPNEXT_REPORT_TOOLS = [ + { + "tool_name": "erpnext_run_report", + "description": "Run any ERPNext script or query report by name and get results. Use erpnext_list_reports to discover available report names and their modules. Common reports: 'Balance Sheet', 'Profit and Loss Statement', 'Cash Flow', 'General Ledger', 'Accounts Receivable', 'Accounts Payable', 'Stock Balance', 'Stock Ledger', 'Sales Register', 'Purchase Register', 'Sales Analytics', 'Sales Pipeline Analytics', 'Lead Details'. Pass filters as a JSON object with keys like company, from_date, to_date, fiscal_year, etc.", + "function_path": "huf.ai.tools.erpnext_reports.handle_run_report", + "category": "ERPNext Reports", + "parameters": [ + _p("report_name", required=True, description="Exact report name (case-sensitive). Use erpnext_list_reports to find valid names."), + _p("filters", description="JSON object of filter key-value pairs. E.g. {\"company\": \"My Company\", \"from_date\": \"2024-01-01\", \"to_date\": \"2024-12-31\"}"), + ], + }, + { + "tool_name": "erpnext_list_reports", + "description": "List available ERPNext reports by module. Use this to discover report names before calling erpnext_run_report. Available modules: Accounts, Selling, Buying, Stock, Manufacturing, CRM, Helpdesk, Projects, HR.", + "function_path": "huf.ai.tools.erpnext_reports.handle_list_reports", + "category": "ERPNext Reports", + "parameters": [ + _p("module", description="Module to filter by: Accounts, Selling, Buying, Stock, Manufacturing, CRM, Helpdesk, Projects, HR. Leave empty to list all."), + ], + }, +] GMAIL_TOOLS = [ { "tool_name": "gmail_get_emails", @@ -404,7 +597,7 @@ def _p(name, type="string", required=False, description=""): # --------------------------------------------------------------------------- -# Master list: every tool grouped for easy iteration +# Master list # --------------------------------------------------------------------------- ALL_INTEGRATION_TOOLS = ( @@ -413,6 +606,13 @@ def _p(name, type="string", required=False, description=""): + DISCORD_TOOLS + TELEGRAM_TOOLS + GITHUB_TOOLS + + CRM_TOOLS + + HELPDESK_TOOLS + + RAVEN_TOOLS + + ERPNEXT_TOOLS + + ERPNEXT_CRM_TOOLS + + ERPNEXT_INVENTORY_TOOLS + + ERPNEXT_REPORT_TOOLS + GMAIL_TOOLS + GOOGLE_SHEETS_TOOLS + GOOGLE_CALENDAR_TOOLS diff --git a/huf/ai/tools/crm.py b/huf/ai/tools/crm.py new file mode 100644 index 00000000..ac26aec8 --- /dev/null +++ b/huf/ai/tools/crm.py @@ -0,0 +1,542 @@ +""" +CRM integration tools for Frappe CRM (FCRM). +Uses direct Frappe DocType APIs – no external HTTP calls. +""" + +import json +import frappe + + +def _crm_installed(): + try: + return "crm" in frappe.get_installed_apps() + except Exception: + return False + + +def _error(msg): + return json.dumps({"success": False, "error": msg}, default=str) + + +# --------------------------------------------------------------------------- +# Leads +# --------------------------------------------------------------------------- + +def _handle_get_leads(**kwargs) -> str: + """List leads with optional filters (status, assigned_to, search query).""" + if not _crm_installed(): + return _error("Frappe CRM app is not installed.") + + try: + filters = {} + status = kwargs.get("status") + lead_owner = kwargs.get("assigned_to") or kwargs.get("lead_owner") + search = kwargs.get("search") + limit = int(kwargs.get("limit", 20)) + + if status: + filters["status"] = status + if lead_owner: + filters["lead_owner"] = lead_owner + + or_filters = [] + if search: + or_filters = [ + ["CRM Lead", "lead_name", "like", f"%{search}%"], + ["CRM Lead", "email", "like", f"%{search}%"], + ["CRM Lead", "mobile_no", "like", f"%{search}%"], + ["CRM Lead", "organization", "like", f"%{search}%"], + ] + + fields = [ + "name", + "lead_name", + "first_name", + "last_name", + "email", + "mobile_no", + "status", + "lead_owner", + "organization", + "source", + "modified", + ] + + leads = frappe.get_all( + "CRM Lead", + fields=fields, + filters=filters, + or_filters=or_filters or None, + limit=limit, + order_by="modified desc", + ) + + return json.dumps({"success": True, "count": len(leads), "results": leads}, default=str) + except Exception as e: + frappe.log_error(f"CRM Get Leads Error: {e}", "CRM Tool") + return _error(str(e)) + + +def _handle_get_lead(**kwargs) -> str: + """Get a single lead by name/ID with all fields.""" + if not _crm_installed(): + return _error("Frappe CRM app is not installed.") + + try: + name = kwargs.get("name") or kwargs.get("lead_id") + if not name: + return _error("name or lead_id is required") + + if not frappe.db.exists("CRM Lead", name): + return _error(f"Lead {name} not found") + + doc = frappe.get_doc("CRM Lead", name) + return json.dumps({"success": True, "results": doc.as_dict()}, default=str) + except Exception as e: + frappe.log_error(f"CRM Get Lead Error: {e}", "CRM Tool") + return _error(str(e)) + + +def _handle_create_lead(**kwargs) -> str: + """Create a new lead.""" + if not _crm_installed(): + return _error("Frappe CRM app is not installed.") + + try: + first_name = kwargs.get("first_name") + if not first_name: + return _error("first_name is required") + + last_name = kwargs.get("last_name", "") + lead_name = kwargs.get("lead_name") or f"{first_name} {last_name}".strip() + + doc = frappe.new_doc("CRM Lead") + doc.first_name = first_name + doc.last_name = last_name + doc.lead_name = lead_name + doc.email = kwargs.get("email", "") + doc.mobile_no = kwargs.get("mobile_no", "") + doc.lead_owner = kwargs.get("lead_owner", "") + doc.source = kwargs.get("source", "") + doc.organization = kwargs.get("organization", "") + doc.status = kwargs.get("status", "New") + doc.insert(ignore_permissions=True) + + notes = kwargs.get("notes", "") + if notes: + note = frappe.new_doc("FCRM Note") + note.title = kwargs.get("note_title", "Note") + note.content = notes + note.reference_doctype = "CRM Lead" + note.reference_docname = doc.name + note.insert(ignore_permissions=True) + + return json.dumps({"success": True, "results": {"name": doc.name, "lead_name": doc.lead_name}}, default=str) + except Exception as e: + frappe.log_error(f"CRM Create Lead Error: {e}", "CRM Tool") + return _error(str(e)) + + +def _handle_update_lead(**kwargs) -> str: + """Update lead fields.""" + if not _crm_installed(): + return _error("Frappe CRM app is not installed.") + + try: + name = kwargs.get("name") or kwargs.get("lead_id") + if not name: + return _error("name or lead_id is required") + if not frappe.db.exists("CRM Lead", name): + return _error(f"Lead {name} not found") + + doc = frappe.get_doc("CRM Lead", name) + updatable = [ + "first_name", + "last_name", + "lead_name", + "email", + "mobile_no", + "phone", + "status", + "lead_owner", + "source", + "organization", + "website", + "territory", + "industry", + "job_title", + "annual_revenue", + "no_of_employees", + "gender", + "salutation", + "converted", + ] + for field in updatable: + if field in kwargs: + setattr(doc, field, kwargs[field]) + + doc.save(ignore_permissions=True) + return json.dumps({"success": True, "results": {"name": doc.name, "lead_name": doc.lead_name}}, default=str) + except Exception as e: + frappe.log_error(f"CRM Update Lead Error: {e}", "CRM Tool") + return _error(str(e)) + + +# --------------------------------------------------------------------------- +# Deals +# --------------------------------------------------------------------------- + +def _handle_get_deals(**kwargs) -> str: + """List deals with optional filters.""" + if not _crm_installed(): + return _error("Frappe CRM app is not installed.") + + try: + filters = {} + status = kwargs.get("status") + deal_owner = kwargs.get("deal_owner") + organization = kwargs.get("organization") + search = kwargs.get("search") + limit = int(kwargs.get("limit", 20)) + + if status: + filters["status"] = status + if deal_owner: + filters["deal_owner"] = deal_owner + if organization: + filters["organization"] = organization + + or_filters = [] + if search: + or_filters = [ + ["CRM Deal", "organization", "like", f"%{search}%"], + ["CRM Deal", "lead_name", "like", f"%{search}%"], + ["CRM Deal", "email", "like", f"%{search}%"], + ] + + fields = [ + "name", + "organization", + "status", + "deal_owner", + "annual_revenue", + "probability", + "close_date", + "modified", + ] + + deals = frappe.get_all( + "CRM Deal", + fields=fields, + filters=filters, + or_filters=or_filters or None, + limit=limit, + order_by="modified desc", + ) + + return json.dumps({"success": True, "count": len(deals), "results": deals}, default=str) + except Exception as e: + frappe.log_error(f"CRM Get Deals Error: {e}", "CRM Tool") + return _error(str(e)) + + +def _handle_get_deal(**kwargs) -> str: + """Get a single deal by name/ID with all fields.""" + if not _crm_installed(): + return _error("Frappe CRM app is not installed.") + + try: + name = kwargs.get("name") or kwargs.get("deal_id") + if not name: + return _error("name or deal_id is required") + if not frappe.db.exists("CRM Deal", name): + return _error(f"Deal {name} not found") + + doc = frappe.get_doc("CRM Deal", name) + return json.dumps({"success": True, "results": doc.as_dict()}, default=str) + except Exception as e: + frappe.log_error(f"CRM Get Deal Error: {e}", "CRM Tool") + return _error(str(e)) + + +def _handle_create_deal(**kwargs) -> str: + """Create a deal from a lead or standalone.""" + if not _crm_installed(): + return _error("Frappe CRM app is not installed.") + + try: + doc = frappe.new_doc("CRM Deal") + + lead = kwargs.get("lead") + if lead: + if not frappe.db.exists("CRM Lead", lead): + return _error(f"Lead {lead} not found") + doc.lead = lead + lead_doc = frappe.get_doc("CRM Lead", lead) + doc.lead_name = lead_doc.lead_name + doc.organization_name = lead_doc.organization + # Try to link existing CRM Organization; leave blank if not found + if lead_doc.organization and frappe.db.exists("CRM Organization", lead_doc.organization): + doc.organization = lead_doc.organization + doc.email = lead_doc.email + doc.mobile_no = lead_doc.mobile_no + doc.phone = lead_doc.phone + doc.first_name = lead_doc.first_name + doc.last_name = lead_doc.last_name + doc.salutation = lead_doc.salutation + doc.gender = lead_doc.gender + doc.source = lead_doc.source + doc.territory = lead_doc.territory + doc.industry = lead_doc.industry + doc.annual_revenue = lead_doc.annual_revenue + doc.no_of_employees = lead_doc.no_of_employees + doc.job_title = lead_doc.job_title + doc.website = lead_doc.website + + # Override with explicit kwargs if provided + if "organization_name" in kwargs: + doc.organization_name = kwargs["organization_name"] + if "organization" in kwargs: + org = kwargs["organization"] + if org and frappe.db.exists("CRM Organization", org): + doc.organization = org + elif org and not doc.organization_name: + doc.organization_name = org + + doc.status = kwargs.get("status", "Qualification") + doc.deal_owner = kwargs.get("deal_owner", "") + doc.annual_revenue = kwargs.get("deal_value", kwargs.get("annual_revenue", 0)) + doc.probability = kwargs.get("probability", 0) + doc.close_date = kwargs.get("expected_closure_date", kwargs.get("close_date", "")) + doc.email = kwargs.get("email", doc.email or "") + doc.mobile_no = kwargs.get("mobile_no", doc.mobile_no or "") + doc.first_name = kwargs.get("first_name", doc.first_name or "") + doc.last_name = kwargs.get("last_name", doc.last_name or "") + + doc.insert(ignore_permissions=True) + return json.dumps({"success": True, "results": {"name": doc.name, "organization": doc.organization_name or doc.organization}}, default=str) + except Exception as e: + frappe.log_error(f"CRM Create Deal Error: {e}", "CRM Tool") + return _error(str(e)) + + +def _handle_update_deal(**kwargs) -> str: + """Update deal fields.""" + if not _crm_installed(): + return _error("Frappe CRM app is not installed.") + + try: + name = kwargs.get("name") or kwargs.get("deal_id") + if not name: + return _error("name or deal_id is required") + if not frappe.db.exists("CRM Deal", name): + return _error(f"Deal {name} not found") + + doc = frappe.get_doc("CRM Deal", name) + updatable = [ + "organization", + "status", + "deal_owner", + "annual_revenue", + "probability", + "close_date", + "next_step", + "email", + "mobile_no", + "phone", + "website", + "territory", + "industry", + "lost_reason", + "lost_notes", + ] + for field in updatable: + if field in kwargs: + setattr(doc, field, kwargs[field]) + + if "deal_value" in kwargs: + doc.annual_revenue = kwargs["deal_value"] + if "expected_closure_date" in kwargs: + doc.close_date = kwargs["expected_closure_date"] + + doc.save(ignore_permissions=True) + return json.dumps({"success": True, "results": {"name": doc.name, "organization": doc.organization}}, default=str) + except Exception as e: + frappe.log_error(f"CRM Update Deal Error: {e}", "CRM Tool") + return _error(str(e)) + + +# --------------------------------------------------------------------------- +# Notes & Tasks +# --------------------------------------------------------------------------- + +def _handle_add_note(**kwargs) -> str: + """Add a note to a lead or deal.""" + if not _crm_installed(): + return _error("Frappe CRM app is not installed.") + + try: + doctype = kwargs.get("doctype") + docname = kwargs.get("docname") + content = kwargs.get("content") + if not all([doctype, docname, content]): + return _error("doctype, docname, and content are required") + + if not frappe.db.exists(doctype, docname): + return _error(f"Document {doctype} {docname} not found") + + note = frappe.new_doc("FCRM Note") + note.title = kwargs.get("title", "Note") + note.content = content + note.reference_doctype = doctype + note.reference_docname = docname + note.insert(ignore_permissions=True) + + return json.dumps({"success": True, "results": {"name": note.name, "title": note.title}}, default=str) + except Exception as e: + frappe.log_error(f"CRM Add Note Error: {e}", "CRM Tool") + return _error(str(e)) + + +def _handle_add_task(**kwargs) -> str: + """Create a task linked to a lead or deal.""" + if not _crm_installed(): + return _error("Frappe CRM app is not installed.") + + try: + title = kwargs.get("title") + reference_doctype = kwargs.get("reference_doctype") + reference_docname = kwargs.get("reference_docname") + if not all([title, reference_doctype, reference_docname]): + return _error("title, reference_doctype, and reference_docname are required") + + if not frappe.db.exists(reference_doctype, reference_docname): + return _error(f"Document {reference_doctype} {reference_docname} not found") + + task = frappe.new_doc("CRM Task") + task.title = title + task.reference_doctype = reference_doctype + task.reference_docname = reference_docname + task.assigned_to = kwargs.get("assigned_to", "") + task.status = kwargs.get("status", "Todo") + task.priority = kwargs.get("priority", "Medium") + task.due_date = kwargs.get("due_date", "") + task.description = kwargs.get("description", "") + task.start_date = kwargs.get("start_date", "") + task.insert(ignore_permissions=True) + + return json.dumps({"success": True, "results": {"name": task.name, "title": task.title}}, default=str) + except Exception as e: + frappe.log_error(f"CRM Add Task Error: {e}", "CRM Tool") + return _error(str(e)) + + +# --------------------------------------------------------------------------- +# Contacts +# --------------------------------------------------------------------------- + +def _handle_get_contacts(**kwargs) -> str: + """List/search contacts. Optionally filter by deal to return CRM Contacts child rows.""" + if not _crm_installed(): + return _error("Frappe CRM app is not installed.") + + try: + search = kwargs.get("search") + deal = kwargs.get("deal") + limit = int(kwargs.get("limit", 20)) + offset = int(kwargs.get("offset", 0)) + + if deal: + if not frappe.db.exists("CRM Deal", deal): + return _error(f"Deal {deal} not found") + + filters = {"parenttype": "CRM Deal", "parent": deal} + or_filters = [] + if search: + or_filters = [ + ["CRM Contacts", "full_name", "like", f"%{search}%"], + ["CRM Contacts", "email", "like", f"%{search}%"], + ["CRM Contacts", "mobile_no", "like", f"%{search}%"], + ] + + fields = [ + "name", + "contact", + "full_name", + "email", + "mobile_no", + "phone", + "gender", + "is_primary", + "modified", + ] + + contacts = frappe.get_all( + "CRM Contacts", + fields=fields, + filters=filters, + or_filters=or_filters or None, + limit=limit, + limit_start=offset, + order_by="modified desc", + ) + else: + filters = {} + or_filters = [] + if search: + or_filters = [ + ["Contact", "first_name", "like", f"%{search}%"], + ["Contact", "last_name", "like", f"%{search}%"], + ["Contact", "email_id", "like", f"%{search}%"], + ["Contact", "mobile_no", "like", f"%{search}%"], + ] + + fields = [ + "name", + "first_name", + "last_name", + "email_id", + "mobile_no", + "phone", + "company_name", + "modified", + ] + + contacts = frappe.get_all( + "Contact", + fields=fields, + filters=filters, + or_filters=or_filters or None, + limit=limit, + limit_start=offset, + order_by="modified desc", + ) + + return json.dumps({"success": True, "count": len(contacts), "results": contacts}, default=str) + except frappe.DoesNotExistError: + return _error("Contact DocType does not exist on this site.") + except Exception as e: + frappe.log_error(f"CRM Get Contacts Error: {e}", "CRM Tool") + return _error(str(e)) + + +def handle_action(**kwargs) -> str: + action = kwargs.get("action", "").strip().lower() + dispatch = { + "list_leads": _handle_get_leads, + "get_lead": _handle_get_lead, + "create_lead": _handle_create_lead, + "update_lead": _handle_update_lead, + "list_deals": _handle_get_deals, + "get_deal": _handle_get_deal, + "create_deal": _handle_create_deal, + "update_deal": _handle_update_deal, + "add_note": _handle_add_note, + "add_task": _handle_add_task, + "list_contacts": _handle_get_contacts, + } + handler = dispatch.get(action) + if not handler: + valid = ", ".join(sorted(dispatch.keys())) + return json.dumps({"success": False, "error": f"Unknown action '{action}'. Valid: {valid}"}, default=str) + return handler(**kwargs) diff --git a/huf/ai/tools/discord.py b/huf/ai/tools/discord.py index 83878d69..4fd7203f 100644 --- a/huf/ai/tools/discord.py +++ b/huf/ai/tools/discord.py @@ -28,7 +28,7 @@ def handle_send_message(**kwargs) -> str: channel_id = kwargs.get("channel_id") message = kwargs.get("message") if not all([channel_id, message]): - return json.dumps({"success": False, "error": "channel_id and message are required"}) + return json.dumps({"success": False, "error": "channel_id and message are required"}, default=str) headers = _get_discord_headers() payload = {"content": message} @@ -49,7 +49,7 @@ def handle_send_message(**kwargs) -> str: error_msg = f"Discord Send Message Error: {str(e)}" frappe.log_error(error_msg, "Discord Tool") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": str(e)}) + return json.dumps({"success": False, "error": str(e)}, default=str) def handle_get_channel_messages(**kwargs) -> str: @@ -58,7 +58,7 @@ def handle_get_channel_messages(**kwargs) -> str: try: channel_id = kwargs.get("channel_id") if not channel_id: - return json.dumps({"success": False, "error": "channel_id is required"}) + return json.dumps({"success": False, "error": "channel_id is required"}, default=str) limit = int(kwargs.get("limit", 50)) headers = _get_discord_headers() @@ -84,7 +84,7 @@ def handle_get_channel_messages(**kwargs) -> str: error_msg = f"Discord Get Messages Error: {str(e)}" frappe.log_error(error_msg, "Discord Tool") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": str(e)}) + return json.dumps({"success": False, "error": str(e)}, default=str) def handle_list_channels(**kwargs) -> str: @@ -93,7 +93,7 @@ def handle_list_channels(**kwargs) -> str: try: guild_id = kwargs.get("guild_id") if not guild_id: - return json.dumps({"success": False, "error": "guild_id is required"}) + return json.dumps({"success": False, "error": "guild_id is required"}, default=str) headers = _get_discord_headers() @@ -117,7 +117,7 @@ def handle_list_channels(**kwargs) -> str: error_msg = f"Discord List Channels Error: {str(e)}" frappe.log_error(error_msg, "Discord Tool") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": str(e)}) + return json.dumps({"success": False, "error": str(e)}, default=str) def handle_delete_message(**kwargs) -> str: @@ -127,7 +127,7 @@ def handle_delete_message(**kwargs) -> str: channel_id = kwargs.get("channel_id") message_id = kwargs.get("message_id") if not all([channel_id, message_id]): - return json.dumps({"success": False, "error": "channel_id and message_id are required"}) + return json.dumps({"success": False, "error": "channel_id and message_id are required"}, default=str) headers = _get_discord_headers() @@ -146,4 +146,4 @@ def handle_delete_message(**kwargs) -> str: error_msg = f"Discord Delete Message Error: {str(e)}" frappe.log_error(error_msg, "Discord Tool") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": str(e)}) + return json.dumps({"success": False, "error": str(e)}, default=str) diff --git a/huf/ai/tools/erpnext.py b/huf/ai/tools/erpnext.py new file mode 100644 index 00000000..9c476b4b --- /dev/null +++ b/huf/ai/tools/erpnext.py @@ -0,0 +1,709 @@ +""" +ERPNext integration tools for financial and business documents. +Uses direct Frappe DocType APIs – no external HTTP calls. +""" + +import json +import frappe + + +def _erpnext_installed(): + try: + return "erpnext" in frappe.get_installed_apps() + except Exception: + return False + + +def _error(msg): + return json.dumps({"success": False, "error": msg}, default=str) + + +def _docstatus_label(ds): + return {0: "Draft", 1: "Submitted", 2: "Cancelled"}.get(ds, str(ds)) + + +# --------------------------------------------------------------------------- +# Sales Invoice +# --------------------------------------------------------------------------- + +def _handle_get_sales_invoices(**kwargs) -> str: + """List Sales Invoices with optional filters.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = [] + customer = kwargs.get("customer") + status = kwargs.get("status") + from_date = kwargs.get("from_date") + to_date = kwargs.get("to_date") + limit = int(kwargs.get("limit", 20)) + + if customer: + filters.append(["Sales Invoice", "customer", "=", customer]) + + if status: + if status == "Draft": + filters.append(["Sales Invoice", "docstatus", "=", 0]) + elif status == "Submitted": + filters.append(["Sales Invoice", "docstatus", "=", 1]) + elif status == "Cancelled": + filters.append(["Sales Invoice", "docstatus", "=", 2]) + else: + filters.append(["Sales Invoice", "status", "=", status]) + + if from_date: + filters.append(["Sales Invoice", "posting_date", ">=", from_date]) + if to_date: + filters.append(["Sales Invoice", "posting_date", "<=", to_date]) + + fields = [ + "name", + "customer", + "customer_name", + "posting_date", + "due_date", + "grand_total", + "outstanding_amount", + "status", + "docstatus", + ] + + invoices = frappe.get_all( + "Sales Invoice", + fields=fields, + filters=filters, + limit=limit, + order_by="posting_date desc", + ) + + for inv in invoices: + inv["docstatus_label"] = _docstatus_label(inv.get("docstatus")) + + return json.dumps({"success": True, "count": len(invoices), "results": invoices}, default=str) + except Exception as e: + frappe.log_error(f"ERPNext Get Sales Invoices Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +def _handle_get_sales_invoice(**kwargs) -> str: + """Get a single Sales Invoice by name/ID with all fields including items.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + name = kwargs.get("name") + if not name: + return _error("name is required") + if not frappe.db.exists("Sales Invoice", name): + return _error(f"Sales Invoice {name} not found") + + doc = frappe.get_doc("Sales Invoice", name) + result = doc.as_dict() + result["docstatus_label"] = _docstatus_label(result.get("docstatus")) + return json.dumps({"success": True, "results": result}, default=str) + except Exception as e: + frappe.log_error(f"ERPNext Get Sales Invoice Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +def _handle_create_sales_invoice(**kwargs) -> str: + """Create a draft Sales Invoice.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + customer = kwargs.get("customer") + if not customer: + return _error("customer is required") + + doc = frappe.new_doc("Sales Invoice") + doc.customer = customer + doc.company = kwargs.get("company", "") + doc.posting_date = kwargs.get("posting_date", "") + + items = kwargs.get("items", []) + if isinstance(items, str): + items = json.loads(items) + for item in items: + doc.append( + "items", + { + "item_code": item.get("item_code"), + "qty": item.get("qty", 1), + "rate": item.get("rate", 0), + }, + ) + + doc.insert(ignore_permissions=True) + return json.dumps( + {"success": True, "results": {"name": doc.name, "customer": doc.customer}} + ) + except Exception as e: + frappe.log_error(f"ERPNext Create Sales Invoice Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +# --------------------------------------------------------------------------- +# Purchase Invoice +# --------------------------------------------------------------------------- + +def _handle_get_purchase_invoices(**kwargs) -> str: + """List Purchase Invoices with optional filters.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = [] + supplier = kwargs.get("supplier") + status = kwargs.get("status") + from_date = kwargs.get("from_date") + to_date = kwargs.get("to_date") + limit = int(kwargs.get("limit", 20)) + + if supplier: + filters.append(["Purchase Invoice", "supplier", "=", supplier]) + + if status: + if status == "Draft": + filters.append(["Purchase Invoice", "docstatus", "=", 0]) + elif status == "Submitted": + filters.append(["Purchase Invoice", "docstatus", "=", 1]) + elif status == "Cancelled": + filters.append(["Purchase Invoice", "docstatus", "=", 2]) + else: + filters.append(["Purchase Invoice", "status", "=", status]) + + if from_date: + filters.append(["Purchase Invoice", "posting_date", ">=", from_date]) + if to_date: + filters.append(["Purchase Invoice", "posting_date", "<=", to_date]) + + fields = [ + "name", + "supplier", + "supplier_name", + "posting_date", + "due_date", + "bill_no", + "total", + "grand_total", + "outstanding_amount", + "status", + "docstatus", + ] + + invoices = frappe.get_all( + "Purchase Invoice", + fields=fields, + filters=filters, + limit=limit, + order_by="posting_date desc", + ) + + for inv in invoices: + inv["docstatus_label"] = _docstatus_label(inv.get("docstatus")) + + return json.dumps({"success": True, "count": len(invoices), "results": invoices}, default=str) + except Exception as e: + frappe.log_error(f"ERPNext Get Purchase Invoices Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +def _handle_get_purchase_invoice(**kwargs) -> str: + """Get a single Purchase Invoice by name/ID with all fields including items.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + name = kwargs.get("name") + if not name: + return _error("name is required") + if not frappe.db.exists("Purchase Invoice", name): + return _error(f"Purchase Invoice {name} not found") + + doc = frappe.get_doc("Purchase Invoice", name) + result = doc.as_dict() + result["docstatus_label"] = _docstatus_label(result.get("docstatus")) + return json.dumps({"success": True, "results": result}, default=str) + except Exception as e: + frappe.log_error(f"ERPNext Get Purchase Invoice Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +# --------------------------------------------------------------------------- +# Payment Entry +# --------------------------------------------------------------------------- + +def _handle_get_payments(**kwargs) -> str: + """List Payment Entries with optional filters.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = [] + party_type = kwargs.get("party_type") + party = kwargs.get("party") + payment_type = kwargs.get("payment_type") + from_date = kwargs.get("from_date") + to_date = kwargs.get("to_date") + limit = int(kwargs.get("limit", 20)) + + if party_type: + filters.append(["Payment Entry", "party_type", "=", party_type]) + if party: + filters.append(["Payment Entry", "party", "=", party]) + if payment_type: + filters.append(["Payment Entry", "payment_type", "=", payment_type]) + if from_date: + filters.append(["Payment Entry", "posting_date", ">=", from_date]) + if to_date: + filters.append(["Payment Entry", "posting_date", "<=", to_date]) + + fields = [ + "name", + "payment_type", + "party_type", + "party", + "party_name", + "paid_amount", + "posting_date", + "mode_of_payment", + "status", + ] + + payments = frappe.get_all( + "Payment Entry", + fields=fields, + filters=filters, + limit=limit, + order_by="posting_date desc", + ) + + return json.dumps({"success": True, "count": len(payments), "results": payments}, default=str) + except Exception as e: + frappe.log_error(f"ERPNext Get Payments Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +def _handle_create_payment(**kwargs) -> str: + """Create a draft Payment Entry. Optionally link to an invoice.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + payment_type = kwargs.get("payment_type") + party_type = kwargs.get("party_type") + party = kwargs.get("party") + if not payment_type: + return _error("payment_type is required") + if not party_type: + return _error("party_type is required") + if not party: + return _error("party is required") + + doc = frappe.new_doc("Payment Entry") + doc.payment_type = payment_type + doc.party_type = party_type + doc.party = party + doc.company = kwargs.get("company", "") + doc.posting_date = kwargs.get("posting_date", "") + doc.mode_of_payment = kwargs.get("mode_of_payment", "") + doc.paid_from = kwargs.get("paid_from", "") + doc.paid_to = kwargs.get("paid_to", "") + doc.paid_amount = float(kwargs.get("paid_amount", 0)) + doc.received_amount = float(kwargs.get("received_amount", doc.paid_amount)) + + invoice_name = kwargs.get("invoice_name") + if invoice_name: + ref_doctype = None + if frappe.db.exists("Sales Invoice", invoice_name): + ref_doctype = "Sales Invoice" + elif frappe.db.exists("Purchase Invoice", invoice_name): + ref_doctype = "Purchase Invoice" + + if ref_doctype: + doc.append( + "references", + { + "reference_doctype": ref_doctype, + "reference_name": invoice_name, + "allocated_amount": doc.paid_amount, + }, + ) + + doc.insert(ignore_permissions=True) + return json.dumps({"success": True, "results": {"name": doc.name}}, default=str) + except Exception as e: + frappe.log_error(f"ERPNext Create Payment Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +# --------------------------------------------------------------------------- +# Quotation +# --------------------------------------------------------------------------- + +def _handle_get_quotations(**kwargs) -> str: + """List Quotations with optional filters.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = [] + party_name = kwargs.get("party_name") + status = kwargs.get("status") + from_date = kwargs.get("from_date") + to_date = kwargs.get("to_date") + limit = int(kwargs.get("limit", 20)) + + if party_name: + filters.append(["Quotation", "party_name", "=", party_name]) + if status: + filters.append(["Quotation", "status", "=", status]) + if from_date: + filters.append(["Quotation", "transaction_date", ">=", from_date]) + if to_date: + filters.append(["Quotation", "transaction_date", "<=", to_date]) + + fields = [ + "name", + "quotation_to", + "party_name", + "customer_name", + "company", + "transaction_date", + "valid_till", + "currency", + "total", + "grand_total", + "status", + "docstatus", + ] + + quotes = frappe.get_all( + "Quotation", + fields=fields, + filters=filters, + limit=limit, + order_by="transaction_date desc", + ) + + for q in quotes: + q["docstatus_label"] = _docstatus_label(q.get("docstatus")) + + return json.dumps({"success": True, "count": len(quotes), "results": quotes}, default=str) + except Exception as e: + frappe.log_error(f"ERPNext Get Quotations Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +def _handle_create_quotation(**kwargs) -> str: + """Create a draft Quotation.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + quotation_to = kwargs.get("quotation_to") + party_name = kwargs.get("party_name") + if not quotation_to: + return _error("quotation_to is required") + if not party_name: + return _error("party_name is required") + + doc = frappe.new_doc("Quotation") + doc.quotation_to = quotation_to + doc.party_name = party_name + doc.company = kwargs.get("company", "") + doc.transaction_date = kwargs.get("transaction_date", "") + doc.valid_till = kwargs.get("valid_till", "") + + items = kwargs.get("items", []) + if isinstance(items, str): + items = json.loads(items) + for item in items: + doc.append( + "items", + { + "item_code": item.get("item_code"), + "qty": item.get("qty", 1), + "rate": item.get("rate", 0), + }, + ) + + doc.insert(ignore_permissions=True) + return json.dumps({"success": True, "results": {"name": doc.name}}, default=str) + except Exception as e: + frappe.log_error(f"ERPNext Create Quotation Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +# --------------------------------------------------------------------------- +# Customer +# --------------------------------------------------------------------------- + +def _handle_get_customers(**kwargs) -> str: + """List/search Customers with optional filters.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = [] + customer_group = kwargs.get("customer_group") + territory = kwargs.get("territory") + search = kwargs.get("search") + limit = int(kwargs.get("limit", 20)) + + if customer_group: + filters.append(["Customer", "customer_group", "=", customer_group]) + if territory: + filters.append(["Customer", "territory", "=", territory]) + + or_filters = [] + if search: + search_term = f"%{search.replace(' ', '%')}%" + or_filters = [ + ["Customer", "name", "like", search_term], + ["Customer", "customer_name", "like", search_term], + ["Customer", "customer_group", "like", search_term], + ] + + fields = [ + "name", + "customer_name", + "customer_type", + "customer_group", + "territory", + "default_currency", + "disabled", + ] + + customers = frappe.get_all( + "Customer", + fields=fields, + filters=filters, + or_filters=or_filters or None, + limit=limit, + order_by="modified desc", + ) + + return json.dumps({"success": True, "count": len(customers), "results": customers}, default=str) + except Exception as e: + frappe.log_error(f"ERPNext Get Customers Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +def _handle_get_customer(**kwargs) -> str: + """Get a single Customer by name/ID with linked addresses and contacts.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + name = kwargs.get("name") + if not name: + return _error("name is required") + if not frappe.db.exists("Customer", name): + return _error(f"Customer {name} not found") + + doc = frappe.get_doc("Customer", name) + result = doc.as_dict() + + # Linked addresses via Dynamic Link + address_links = frappe.get_all( + "Dynamic Link", + filters={ + "link_doctype": "Customer", + "link_name": name, + "parenttype": "Address", + }, + fields=["parent"], + ) + result["addresses"] = [] + for link in address_links: + addr = frappe.get_doc("Address", link.parent) + result["addresses"].append(addr.as_dict()) + + # Linked contacts via Dynamic Link + contact_links = frappe.get_all( + "Dynamic Link", + filters={ + "link_doctype": "Customer", + "link_name": name, + "parenttype": "Contact", + }, + fields=["parent"], + ) + result["contacts"] = [] + for link in contact_links: + contact = frappe.get_doc("Contact", link.parent) + result["contacts"].append(contact.as_dict()) + + return json.dumps({"success": True, "results": result}, default=str) + except Exception as e: + frappe.log_error(f"ERPNext Get Customer Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +# --------------------------------------------------------------------------- +# GL Entry (read-only) +# --------------------------------------------------------------------------- + +def _handle_get_account_ledger(**kwargs) -> str: + """Query GL Entries for an account with running balance. GL Entry is read-only.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + account = kwargs.get("account") + if not account: + return _error("account is required") + + filters = [["GL Entry", "account", "=", account]] + from_date = kwargs.get("from_date") + to_date = kwargs.get("to_date") + party_type = kwargs.get("party_type") + party = kwargs.get("party") + limit = int(kwargs.get("limit", 50)) + + if from_date: + filters.append(["GL Entry", "posting_date", ">=", from_date]) + if to_date: + filters.append(["GL Entry", "posting_date", "<=", to_date]) + if party_type: + filters.append(["GL Entry", "party_type", "=", party_type]) + if party: + filters.append(["GL Entry", "party", "=", party]) + + fields = [ + "name", + "account", + "party_type", + "party", + "posting_date", + "debit", + "credit", + "voucher_type", + "voucher_no", + "cost_center", + "remarks", + ] + + entries = frappe.get_all( + "GL Entry", + fields=fields, + filters=filters, + limit=limit, + order_by="posting_date asc, creation asc", + ) + + running_balance = 0.0 + for entry in entries: + running_balance += float(entry.get("debit", 0) or 0) - float(entry.get("credit", 0) or 0) + entry["running_balance"] = running_balance + + return json.dumps({"success": True, "count": len(entries), "results": entries}, default=str) + except Exception as e: + frappe.log_error(f"ERPNext Get Account Ledger Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +# --------------------------------------------------------------------------- +# Journal Entry +# --------------------------------------------------------------------------- + +def _handle_create_journal_entry(**kwargs) -> str: + """Create a draft Journal Entry with account lines.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + doc = frappe.new_doc("Journal Entry") + doc.voucher_type = kwargs.get("voucher_type", "Journal Entry") + doc.posting_date = kwargs.get("posting_date", "") + doc.company = kwargs.get("company", "") + doc.user_remark = kwargs.get("user_remark", "") + + accounts = kwargs.get("accounts", []) + if isinstance(accounts, str): + accounts = json.loads(accounts) + for acc in accounts: + doc.append( + "accounts", + { + "account": acc.get("account"), + "debit_in_account_currency": acc.get("debit_in_account_currency", 0), + "credit_in_account_currency": acc.get("credit_in_account_currency", 0), + "party_type": acc.get("party_type", ""), + "party": acc.get("party", ""), + "cost_center": acc.get("cost_center", ""), + }, + ) + + doc.insert(ignore_permissions=True) + return json.dumps({"success": True, "results": {"name": doc.name}}, default=str) + except Exception as e: + frappe.log_error(f"ERPNext Create Journal Entry Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +# --------------------------------------------------------------------------- +# Request for Quotation +# --------------------------------------------------------------------------- + +def _handle_get_rfqs(**kwargs) -> str: + """List Request for Quotation documents with optional filters.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = [] + status = kwargs.get("status") + from_date = kwargs.get("from_date") + to_date = kwargs.get("to_date") + limit = int(kwargs.get("limit", 20)) + + if status: + filters.append(["Request for Quotation", "status", "=", status]) + if from_date: + filters.append(["Request for Quotation", "transaction_date", ">=", from_date]) + if to_date: + filters.append(["Request for Quotation", "transaction_date", "<=", to_date]) + + fields = ["name", "company", "transaction_date", "status"] + + rfqs = frappe.get_all( + "Request for Quotation", + fields=fields, + filters=filters, + limit=limit, + order_by="transaction_date desc", + ) + + return json.dumps({"success": True, "count": len(rfqs), "results": rfqs}, default=str) + except Exception as e: + frappe.log_error(f"ERPNext Get RFQs Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +def handle_action(**kwargs) -> str: + action = kwargs.get("action", "").strip().lower() + dispatch = { + "list_sales_invoices": _handle_get_sales_invoices, + "get_sales_invoice": _handle_get_sales_invoice, + "create_sales_invoice": _handle_create_sales_invoice, + "list_purchase_invoices": _handle_get_purchase_invoices, + "get_purchase_invoice": _handle_get_purchase_invoice, + "list_payments": _handle_get_payments, + "create_payment": _handle_create_payment, + "list_customers": _handle_get_customers, + "get_customer": _handle_get_customer, + "list_quotations": _handle_get_quotations, + "create_quotation": _handle_create_quotation, + "list_rfqs": _handle_get_rfqs, + "get_ledger": _handle_get_account_ledger, + "create_journal_entry": _handle_create_journal_entry, + } + handler = dispatch.get(action) + if not handler: + valid = ", ".join(sorted(dispatch.keys())) + return json.dumps({"success": False, "error": f"Unknown action '{action}'. Valid: {valid}"}, default=str) + return handler(**kwargs) diff --git a/huf/ai/tools/erpnext_crm.py b/huf/ai/tools/erpnext_crm.py new file mode 100644 index 00000000..d86cef51 --- /dev/null +++ b/huf/ai/tools/erpnext_crm.py @@ -0,0 +1,291 @@ +""" +ERPNext CRM integration tools (Lead, Opportunity). +Different from standalone Frappe CRM (CRM Lead / CRM Deal). +Uses direct Frappe DocType APIs – no external HTTP calls. +""" + +import json +import frappe + + +def _erpnext_installed(): + try: + return "erpnext" in frappe.get_installed_apps() + except Exception: + return False + + +def _error(msg): + return json.dumps({"success": False, "error": msg}, default=str) + + +# --------------------------------------------------------------------------- +# Lead +# --------------------------------------------------------------------------- + +def _handle_get_leads(**kwargs) -> str: + """List ERPNext leads with optional filters.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = [] + status = kwargs.get("status") + lead_owner = kwargs.get("lead_owner") + search = kwargs.get("search") + limit = int(kwargs.get("limit", 20)) + + if status: + filters.append(["Lead", "status", "=", status]) + if lead_owner: + filters.append(["Lead", "lead_owner", "=", lead_owner]) + + or_filters = [] + if search: + search_term = f"%{search.replace(' ', '%')}%" + or_filters = [ + ["Lead", "lead_name", "like", search_term], + ["Lead", "company_name", "like", search_term], + ["Lead", "email_id", "like", search_term], + ] + + fields = [ + "name", + "lead_name", + "company_name", + "email_id", + "status", + "lead_owner", + "mobile_no", + "territory", + "modified", + ] + + leads = frappe.get_all( + "Lead", + fields=fields, + filters=filters, + or_filters=or_filters or None, + limit=limit, + order_by="modified desc", + ) + + return json.dumps({"success": True, "count": len(leads), "results": leads}, default=str) + except Exception as e: + frappe.log_error(f"ERPNext CRM Get Leads Error: {e}", "ERPNext CRM Tool") + return _error(str(e)) + + +def _handle_get_lead(**kwargs) -> str: + """Get a single ERPNext Lead by name/ID with all fields.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + name = kwargs.get("name") + if not name: + return _error("name is required") + if not frappe.db.exists("Lead", name): + return _error(f"Lead {name} not found") + + doc = frappe.get_doc("Lead", name) + return json.dumps({"success": True, "results": doc.as_dict()}, default=str) + except Exception as e: + frappe.log_error(f"ERPNext CRM Get Lead Error: {e}", "ERPNext CRM Tool") + return _error(str(e)) + + +def _handle_create_lead(**kwargs) -> str: + """Create a new ERPNext Lead.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + lead_name = kwargs.get("lead_name") + if not lead_name: + return _error("lead_name is required") + + doc = frappe.new_doc("Lead") + doc.lead_name = lead_name + doc.company_name = kwargs.get("company_name", "") + doc.email_id = kwargs.get("email_id", "") + doc.mobile_no = kwargs.get("mobile_no", "") + doc.phone = kwargs.get("phone", "") + doc.lead_owner = kwargs.get("lead_owner", "") + doc.status = kwargs.get("status", "Lead") + doc.type = kwargs.get("type", "") + doc.market_segment = kwargs.get("market_segment", "") + doc.industry = kwargs.get("industry", "") + doc.territory = kwargs.get("territory", "") + doc.website = kwargs.get("website", "") + doc.insert(ignore_permissions=True) + + return json.dumps( + {"success": True, "results": {"name": doc.name, "lead_name": doc.lead_name}} + ) + except Exception as e: + frappe.log_error(f"ERPNext CRM Create Lead Error: {e}", "ERPNext CRM Tool") + return _error(str(e)) + + +def _handle_update_lead(**kwargs) -> str: + """Update fields on an existing ERPNext Lead.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + name = kwargs.get("name") + if not name: + return _error("name is required") + if not frappe.db.exists("Lead", name): + return _error(f"Lead {name} not found") + + doc = frappe.get_doc("Lead", name) + restricted_fields = {"name", "doctype", "creation", "modified", "modified_by", "owner", "docstatus", "action"} + for field, value in kwargs.items(): + if field in restricted_fields: + continue + if doc.meta.has_field(field): + df = doc.meta.get_field(field) + if df.fieldtype not in ("Table", "Table MultiSelect"): + setattr(doc, field, value) + + doc.save(ignore_permissions=True) + return json.dumps( + {"success": True, "results": {"name": doc.name, "lead_name": doc.lead_name}} + ) + except Exception as e: + frappe.log_error(f"ERPNext CRM Update Lead Error: {e}", "ERPNext CRM Tool") + return _error(str(e)) + + +# --------------------------------------------------------------------------- +# Opportunity +# --------------------------------------------------------------------------- + +def _handle_get_opportunities(**kwargs) -> str: + """List ERPNext Opportunities with optional filters.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = [] + status = kwargs.get("status") + party_name = kwargs.get("party_name") + from_date = kwargs.get("from_date") + limit = int(kwargs.get("limit", 20)) + + if status: + filters.append(["Opportunity", "status", "=", status]) + if party_name: + filters.append(["Opportunity", "party_name", "=", party_name]) + if from_date: + filters.append(["Opportunity", "expected_closing", ">=", from_date]) + + fields = [ + "name", + "title", + "party_name", + "customer_name", + "status", + "opportunity_amount", + "sales_stage", + "probability", + "expected_closing", + ] + + opportunities = frappe.get_all( + "Opportunity", + fields=fields, + filters=filters, + limit=limit, + order_by="modified desc", + ) + + return json.dumps( + {"success": True, "count": len(opportunities), "results": opportunities} + ) + except Exception as e: + frappe.log_error(f"ERPNext CRM Get Opportunities Error: {e}", "ERPNext CRM Tool") + return _error(str(e)) + + +def _handle_create_opportunity(**kwargs) -> str: + """Create a new ERPNext Opportunity.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + opportunity_from = kwargs.get("opportunity_from") + party_name = kwargs.get("party_name") + if not opportunity_from: + return _error("opportunity_from is required") + if not party_name: + return _error("party_name is required") + + doc = frappe.new_doc("Opportunity") + doc.opportunity_from = opportunity_from + doc.party_name = party_name + doc.title = kwargs.get("title", "") + doc.opportunity_type = kwargs.get("opportunity_type", "") + doc.expected_closing = kwargs.get("expected_closing", "") + doc.opportunity_amount = float(kwargs.get("opportunity_amount", 0)) + doc.sales_stage = kwargs.get("sales_stage", "") + doc.probability = float(kwargs.get("probability", 0)) + doc.currency = kwargs.get("currency", "") + doc.insert(ignore_permissions=True) + + return json.dumps({"success": True, "results": {"name": doc.name}}, default=str) + except Exception as e: + frappe.log_error(f"ERPNext CRM Create Opportunity Error: {e}", "ERPNext CRM Tool") + return _error(str(e)) + + +def _handle_update_opportunity(**kwargs) -> str: + """Update fields on an existing ERPNext Opportunity.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + name = kwargs.get("name") + if not name: + return _error("name is required") + if not frappe.db.exists("Opportunity", name): + return _error(f"Opportunity {name} not found") + + doc = frappe.get_doc("Opportunity", name) + restricted_fields = {"name", "doctype", "creation", "modified", "modified_by", "owner", "docstatus", "action"} + for field, value in kwargs.items(): + if field in restricted_fields: + continue + if doc.meta.has_field(field): + df = doc.meta.get_field(field) + if df.fieldtype not in ("Table", "Table MultiSelect"): + if df.fieldtype in ("Currency", "Float", "Percent"): + setattr(doc, field, float(value) if value else 0.0) + else: + setattr(doc, field, value) + + doc.save(ignore_permissions=True) + return json.dumps({"success": True, "results": {"name": doc.name}}, default=str) + except Exception as e: + frappe.log_error(f"ERPNext CRM Update Opportunity Error: {e}", "ERPNext CRM Tool") + return _error(str(e)) + + +def handle_action(**kwargs) -> str: + action = kwargs.get("action", "").strip().lower() + dispatch = { + "list_leads": _handle_get_leads, + "get_lead": _handle_get_lead, + "create_lead": _handle_create_lead, + "update_lead": _handle_update_lead, + "list_opportunities": _handle_get_opportunities, + "create_opportunity": _handle_create_opportunity, + "update_opportunity": _handle_update_opportunity, + } + handler = dispatch.get(action) + if not handler: + valid = ", ".join(sorted(dispatch.keys())) + return json.dumps({"success": False, "error": f"Unknown action '{action}'. Valid: {valid}"}, default=str) + return handler(**kwargs) diff --git a/huf/ai/tools/erpnext_inventory.py b/huf/ai/tools/erpnext_inventory.py new file mode 100644 index 00000000..e5a0cf7b --- /dev/null +++ b/huf/ai/tools/erpnext_inventory.py @@ -0,0 +1,578 @@ +""" +ERPNext integration tools for Items, BOM, Inventory and Stock. +Uses direct Frappe DocType APIs – no external HTTP calls. +""" + +import json +import frappe + + +def _erpnext_installed(): + try: + return "erpnext" in frappe.get_installed_apps() + except Exception: + return False + + +def _error(msg): + return json.dumps({"success": False, "error": msg}, default=str) + + +def _docstatus_label(ds): + return {0: "Draft", 1: "Submitted", 2: "Cancelled"}.get(ds, str(ds)) + + +# --------------------------------------------------------------------------- +# Items +# --------------------------------------------------------------------------- + +def _handle_get_items(**kwargs) -> str: + """List ERPNext items with optional search and filters.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = [["Item", "disabled", "=", int(kwargs.get("disabled", 0))]] + item_group = kwargs.get("item_group") + is_stock_item = kwargs.get("is_stock_item") + search = kwargs.get("search") + limit = int(kwargs.get("limit", 20)) + + if item_group: + filters.append(["Item", "item_group", "=", item_group]) + if is_stock_item is not None: + filters.append(["Item", "is_stock_item", "=", int(is_stock_item)]) + + or_filters = [] + if search: + search_term = f"%{search.replace(' ', '%')}%" + or_filters = [ + ["Item", "item_code", "like", search_term], + ["Item", "item_name", "like", search_term], + ["Item", "item_group", "like", search_term], + ] + + fields = [ + "name", + "item_code", + "item_name", + "item_group", + "stock_uom", + "is_stock_item", + "disabled", + "standard_rate", + "valuation_rate", + ] + + items = frappe.get_all( + "Item", + fields=fields, + filters=filters, + or_filters=or_filters or None, + limit=limit, + order_by="modified desc", + ) + + return json.dumps({"success": True, "count": len(items), "results": items}, default=str) + except Exception as e: + frappe.log_error(f"ERPNext Get Items Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +def _handle_get_item(**kwargs) -> str: + """Get a single ERPNext item by item_code with item_defaults child table.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + name = kwargs.get("name") + if not name: + return _error("name is required") + if not frappe.db.exists("Item", name): + return _error(f"Item {name} not found") + + doc = frappe.get_doc("Item", name) + result = doc.as_dict() + return json.dumps({"success": True, "results": result}, default=str) + except Exception as e: + frappe.log_error(f"ERPNext Get Item Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +def _handle_get_item_prices(**kwargs) -> str: + """List ERPNext item prices for an item with optional filters.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = [] + item_code = kwargs.get("item_code") + price_list = kwargs.get("price_list") + buying = kwargs.get("buying") + selling = kwargs.get("selling") + limit = int(kwargs.get("limit", 20)) + + if item_code: + filters.append(["Item Price", "item_code", "=", item_code]) + if price_list: + filters.append(["Item Price", "price_list", "=", price_list]) + if buying is not None: + filters.append(["Item Price", "buying", "=", int(buying)]) + if selling is not None: + filters.append(["Item Price", "selling", "=", int(selling)]) + + fields = [ + "name", + "item_code", + "price_list", + "buying", + "selling", + "currency", + "price_list_rate", + "uom", + "valid_from", + ] + + prices = frappe.get_all( + "Item Price", + fields=fields, + filters=filters, + limit=limit, + order_by="modified desc", + ) + + return json.dumps({"success": True, "count": len(prices), "results": prices}, default=str) + except Exception as e: + frappe.log_error(f"ERPNext Get Item Prices Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +# --------------------------------------------------------------------------- +# BOM +# --------------------------------------------------------------------------- + +def _handle_get_boms(**kwargs) -> str: + """List ERPNext BOMs with optional filters.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = [] + item = kwargs.get("item") + is_active = kwargs.get("is_active") + is_default = kwargs.get("is_default") + company = kwargs.get("company") + limit = int(kwargs.get("limit", 20)) + + if item: + filters.append(["BOM", "item", "=", item]) + if is_active is not None: + filters.append(["BOM", "is_active", "=", int(is_active)]) + if is_default is not None: + filters.append(["BOM", "is_default", "=", int(is_default)]) + if company: + filters.append(["BOM", "company", "=", company]) + + fields = [ + "name", + "item", + "item_name", + "quantity", + "uom", + "is_active", + "is_default", + "total_cost", + "company", + "modified", + ] + + boms = frappe.get_all( + "BOM", + fields=fields, + filters=filters, + limit=limit, + order_by="modified desc", + ) + + return json.dumps({"success": True, "count": len(boms), "results": boms}, default=str) + except Exception as e: + frappe.log_error(f"ERPNext Get BOMs Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +def _handle_get_bom(**kwargs) -> str: + """Get a single ERPNext BOM by name with items and operations child tables.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + name = kwargs.get("name") + if not name: + return _error("name is required") + if not frappe.db.exists("BOM", name): + return _error(f"BOM {name} not found") + + doc = frappe.get_doc("BOM", name) + result = doc.as_dict() + return json.dumps({"success": True, "results": result}, default=str) + except Exception as e: + frappe.log_error(f"ERPNext Get BOM Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +def _handle_create_bom(**kwargs) -> str: + """Create a draft ERPNext BOM. Provide item, quantity, and line items.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + item = kwargs.get("item") + if not item: + return _error("item is required") + + doc = frappe.new_doc("BOM") + doc.item = item + doc.quantity = float(kwargs.get("quantity", 1)) + doc.uom = kwargs.get("uom", "") + doc.company = kwargs.get("company", "") + doc.is_default = int(kwargs.get("is_default", 1)) + doc.is_active = 1 + + items = kwargs.get("items", []) + if isinstance(items, str): + items = json.loads(items) + for row in items: + doc.append( + "items", + { + "item_code": row.get("item_code"), + "qty": float(row.get("qty", 1)), + "uom": row.get("uom", ""), + "rate": float(row.get("rate", 0)), + }, + ) + + doc.insert(ignore_permissions=True) + return json.dumps({"success": True, "results": {"name": doc.name, "item": doc.item}}, default=str) + except Exception as e: + frappe.log_error(f"ERPNext Create BOM Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +# --------------------------------------------------------------------------- +# Stock & Inventory +# --------------------------------------------------------------------------- + +def _handle_get_stock_balance(**kwargs) -> str: + """Get current stock balance per item and warehouse from Stock Ledger Entry.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = {"is_cancelled": 0} + item_code = kwargs.get("item_code") + warehouse = kwargs.get("warehouse") + as_of_date = kwargs.get("as_of_date") + + if item_code: + filters["item_code"] = item_code + if warehouse: + filters["warehouse"] = warehouse + if as_of_date: + filters["posting_date"] = ["<=", as_of_date] + + sle_list = frappe.get_all( + "Stock Ledger Entry", + fields=[ + "item_code", + "warehouse", + "qty_after_transaction", + "valuation_rate", + "posting_date", + "posting_time", + ], + filters=filters, + order_by="posting_date desc, posting_time desc, creation desc", + limit=500, + ) + + seen = {} + for sle in sle_list: + key = (sle["item_code"], sle["warehouse"]) + if key not in seen: + seen[key] = sle + + results = [] + for sle in seen.values(): + results.append({ + "item_code": sle["item_code"], + "warehouse": sle["warehouse"], + "qty_after_transaction": sle["qty_after_transaction"], + "valuation_rate": sle["valuation_rate"], + "stock_value": ( + (sle["qty_after_transaction"] or 0) * (sle["valuation_rate"] or 0) + ), + }) + + return json.dumps({"success": True, "count": len(results), "results": results}, default=str) + except Exception as e: + frappe.log_error(f"ERPNext Get Stock Balance Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +def _handle_get_stock_movements(**kwargs) -> str: + """List ERPNext stock ledger entries with optional filters.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = [["Stock Ledger Entry", "is_cancelled", "=", 0]] + item_code = kwargs.get("item_code") + warehouse = kwargs.get("warehouse") + from_date = kwargs.get("from_date") + to_date = kwargs.get("to_date") + voucher_type = kwargs.get("voucher_type") + limit = int(kwargs.get("limit", 50)) + + if item_code: + filters.append(["Stock Ledger Entry", "item_code", "=", item_code]) + if warehouse: + filters.append(["Stock Ledger Entry", "warehouse", "=", warehouse]) + if from_date: + filters.append(["Stock Ledger Entry", "posting_date", ">=", from_date]) + if to_date: + filters.append(["Stock Ledger Entry", "posting_date", "<=", to_date]) + if voucher_type: + filters.append(["Stock Ledger Entry", "voucher_type", "=", voucher_type]) + + fields = [ + "name", + "item_code", + "warehouse", + "posting_date", + "voucher_type", + "voucher_no", + "actual_qty", + "qty_after_transaction", + "incoming_rate", + "valuation_rate", + "company", + ] + + entries = frappe.get_all( + "Stock Ledger Entry", + fields=fields, + filters=filters, + limit=limit, + order_by="posting_date desc, posting_time desc", + ) + + return json.dumps({"success": True, "count": len(entries), "results": entries}, default=str) + except Exception as e: + frappe.log_error(f"ERPNext Get Stock Movements Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +def _handle_get_stock_entries(**kwargs) -> str: + """List ERPNext stock entry documents with optional filters.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = [] + stock_entry_type = kwargs.get("stock_entry_type") + from_date = kwargs.get("from_date") + to_date = kwargs.get("to_date") + docstatus = kwargs.get("docstatus") + limit = int(kwargs.get("limit", 20)) + + if stock_entry_type: + filters.append(["Stock Entry", "stock_entry_type", "=", stock_entry_type]) + if from_date: + filters.append(["Stock Entry", "posting_date", ">=", from_date]) + if to_date: + filters.append(["Stock Entry", "posting_date", "<=", to_date]) + if docstatus is not None: + filters.append(["Stock Entry", "docstatus", "=", int(docstatus)]) + + fields = [ + "name", + "stock_entry_type", + "posting_date", + "company", + "docstatus", + ] + + entries = frappe.get_all( + "Stock Entry", + fields=fields, + filters=filters, + limit=limit, + order_by="posting_date desc", + ) + + for entry in entries: + entry["docstatus_label"] = _docstatus_label(entry.get("docstatus")) + + return json.dumps({"success": True, "count": len(entries), "results": entries}, default=str) + except Exception as e: + frappe.log_error(f"ERPNext Get Stock Entries Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +def _handle_get_warehouses(**kwargs) -> str: + """List ERPNext warehouses with optional filters.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = [["Warehouse", "disabled", "=", int(kwargs.get("disabled", 0))]] + company = kwargs.get("company") + warehouse_type = kwargs.get("warehouse_type") + limit = int(kwargs.get("limit", 50)) + + if company: + filters.append(["Warehouse", "company", "=", company]) + if warehouse_type: + filters.append(["Warehouse", "warehouse_type", "=", warehouse_type]) + + fields = [ + "name", + "warehouse_name", + "warehouse_type", + "company", + "parent_warehouse", + "disabled", + ] + + warehouses = frappe.get_all( + "Warehouse", + fields=fields, + filters=filters, + limit=limit, + order_by="warehouse_name asc", + ) + + return json.dumps({"success": True, "count": len(warehouses), "results": warehouses}, default=str) + except Exception as e: + frappe.log_error(f"ERPNext Get Warehouses Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +def _handle_get_delivery_notes(**kwargs) -> str: + """List ERPNext delivery notes with optional filters.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = [] + customer = kwargs.get("customer") + from_date = kwargs.get("from_date") + to_date = kwargs.get("to_date") + docstatus = kwargs.get("docstatus") + limit = int(kwargs.get("limit", 20)) + + if customer: + filters.append(["Delivery Note", "customer", "=", customer]) + if from_date: + filters.append(["Delivery Note", "posting_date", ">=", from_date]) + if to_date: + filters.append(["Delivery Note", "posting_date", "<=", to_date]) + if docstatus is not None: + filters.append(["Delivery Note", "docstatus", "=", int(docstatus)]) + + fields = [ + "name", + "customer", + "customer_name", + "posting_date", + "status", + "docstatus", + ] + + notes = frappe.get_all( + "Delivery Note", + fields=fields, + filters=filters, + limit=limit, + order_by="posting_date desc", + ) + + for note in notes: + note["docstatus_label"] = _docstatus_label(note.get("docstatus")) + + return json.dumps({"success": True, "count": len(notes), "results": notes}, default=str) + except Exception as e: + frappe.log_error(f"ERPNext Get Delivery Notes Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +def _handle_get_purchase_receipts(**kwargs) -> str: + """List ERPNext purchase receipts with optional filters.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + + try: + filters = [] + supplier = kwargs.get("supplier") + from_date = kwargs.get("from_date") + to_date = kwargs.get("to_date") + docstatus = kwargs.get("docstatus") + limit = int(kwargs.get("limit", 20)) + + if supplier: + filters.append(["Purchase Receipt", "supplier", "=", supplier]) + if from_date: + filters.append(["Purchase Receipt", "posting_date", ">=", from_date]) + if to_date: + filters.append(["Purchase Receipt", "posting_date", "<=", to_date]) + if docstatus is not None: + filters.append(["Purchase Receipt", "docstatus", "=", int(docstatus)]) + + fields = [ + "name", + "supplier", + "supplier_name", + "posting_date", + "status", + "docstatus", + ] + + receipts = frappe.get_all( + "Purchase Receipt", + fields=fields, + filters=filters, + limit=limit, + order_by="posting_date desc", + ) + + for receipt in receipts: + receipt["docstatus_label"] = _docstatus_label(receipt.get("docstatus")) + + return json.dumps({"success": True, "count": len(receipts), "results": receipts}, default=str) + except Exception as e: + frappe.log_error(f"ERPNext Get Purchase Receipts Error: {e}", "ERPNext Tool") + return _error(str(e)) + + +def handle_action(**kwargs) -> str: + action = kwargs.get("action", "").strip().lower() + dispatch = { + "list_items": _handle_get_items, + "get_item": _handle_get_item, + "item_prices": _handle_get_item_prices, + "stock_balance": _handle_get_stock_balance, + "stock_movements": _handle_get_stock_movements, + "list_stock_entries": _handle_get_stock_entries, + "list_warehouses": _handle_get_warehouses, + "list_delivery_notes": _handle_get_delivery_notes, + "list_purchase_receipts": _handle_get_purchase_receipts, + "list_boms": _handle_get_boms, + "get_bom": _handle_get_bom, + "create_bom": _handle_create_bom, + } + handler = dispatch.get(action) + if not handler: + valid = ", ".join(sorted(dispatch.keys())) + return json.dumps({"success": False, "error": f"Unknown action '{action}'. Valid: {valid}"}, default=str) + return handler(**kwargs) diff --git a/huf/ai/tools/erpnext_reports.py b/huf/ai/tools/erpnext_reports.py new file mode 100644 index 00000000..483b777e --- /dev/null +++ b/huf/ai/tools/erpnext_reports.py @@ -0,0 +1,169 @@ +""" +ERPNext integration tools for built-in script reports. +Uses frappe.desk.query_report.run – read-only analytical tools. +""" + +import json +import frappe + + +def _erpnext_installed(): + try: + return "erpnext" in frappe.get_installed_apps() + except Exception: + return False + + +def _error(msg): + return json.dumps({"success": False, "error": msg}, default=str) + + +# Catalogue of available reports per module (used by list_reports) +REPORT_CATALOGUE = { + "Accounts": [ + "Balance Sheet", "Profit and Loss Statement", "Trial Balance", + "Cash Flow", "General Ledger", "Accounts Receivable", + "Accounts Receivable Summary", "Accounts Payable", "Accounts Payable Summary", + "Customer Ledger Summary", "Supplier Ledger Summary", + "Sales Register", "Purchase Register", + "Item-wise Sales Register", "Item-wise Purchase Register", + "Gross Profit", "Gross and Net Profit Report", "Profitability Analysis", + "Bank Reconciliation Statement", "Bank Clearance Summary", + "Payment Ledger", "Budget Variance Report", + "Trial Balance for Party", "Voucher-wise Balance", + ], + "Selling": [ + "Sales Analytics", "Sales Order Analysis", "Sales Order Trends", + "Quotation Trends", "Lost Quotations", "Inactive Customers", + "Customer Acquisition and Loyalty", "Customer Credit Balance", + "Sales Person Commission Summary", "Territory-wise Sales", + "Sales Payment Summary", + ], + "Buying": [ + "Purchase Analytics", "Purchase Order Analysis", "Purchase Order Trends", + "Supplier Quotation Comparison", "Procurement Tracker", + "Requested Items to Order and Receive", + ], + "Stock": [ + "Stock Balance", "Stock Ledger", "Stock Projected Qty", + "Stock Ageing", "Item Shortage Report", "Total Stock Summary", + "Warehouse Wise Stock Balance", "Stock Analytics", + "Available Batch Report", "Batch-Wise Balance History", + "BOM Stock Report", "Item Price Stock", + ], + "Manufacturing": [ + "BOM Explorer", "BOM Stock Report", "BOM Variance Report", + "Work Order Summary", "Production Analytics", + "Job Card Summary", "Production Planning Report", + ], + "CRM": [ + "Sales Pipeline Analytics", "Lead Details", "Lead Owner Efficiency", + "Lost Opportunity", "Opportunity Summary by Sales Stage", + "First Response Time for Opportunity", "Campaign Efficiency", + ], + "Helpdesk": [ + "Ticket Analytics", "Ticket Summary", "First Response Time for Tickets", + "Support Hour Distribution", + ], + "Projects": [ + "Project Summary", "Timesheet Billing Summary", "Daily Timesheet Summary", + ], + "HR": [ + "Salary Register", "Monthly Attendance Sheet", + "Employee Leave Balance", "Employee Analytics", + ], +} + + +def handle_run_report(**kwargs) -> str: + """Run any ERPNext/Frappe script report by name with filters.""" + if not _erpnext_installed(): + return _error("ERPNext is not installed.") + report_name = kwargs.get("report_name") + try: + if not report_name: + return _error("report_name is required. Call erpnext_list_reports to see available reports.") + + # Parse filters - accept dict or JSON string + filters = kwargs.get("filters", {}) + if isinstance(filters, str): + try: + filters = json.loads(filters) + except Exception: + return _error("filters must be a JSON object or dict") + + # Fill company default if not provided + if not filters.get("company"): + default_company = frappe.defaults.get_user_default("Company") or frappe.db.get_single_value("Global Defaults", "default_company") + if default_company: + filters["company"] = default_company + + # Auto-resolve dates from fiscal year if missing + if not filters.get("from_date") and not filters.get("to_date"): + fiscal_year = filters.get("fiscal_year") + if not fiscal_year: + fiscal_year = frappe.defaults.get_user_default("Fiscal Year") or frappe.db.get_single_value("Global Defaults", "current_fiscal_year") + + if fiscal_year: + try: + fy_doc = None + if frappe.db.exists("Fiscal Year", fiscal_year): + fy_doc = frappe.get_doc("Fiscal Year", fiscal_year) + else: + fy_list = frappe.get_all("Fiscal Year", filters={"name": ("like", f"%{fiscal_year}%")}, limit=1) + if fy_list: + fy_doc = frappe.get_doc("Fiscal Year", fy_list[0].name) + + if fy_doc: + filters["from_date"] = fy_doc.year_start_date + filters["to_date"] = fy_doc.year_end_date + filters["period_start_date"] = fy_doc.year_start_date + filters["period_end_date"] = fy_doc.year_end_date + filters["fiscal_year"] = fy_doc.name + except Exception: + pass + + # Set default periodicity for financial statements if missing + if not filters.get("periodicity"): + filters["periodicity"] = "Yearly" + + from frappe.desk.query_report import run as run_report + result = run_report(report_name=report_name, filters=filters, ignore_prepared_report=True) + return json.dumps({ + "success": True, + "report_name": report_name, + "columns": result.get("columns", []), + "results": result.get("result", []), + "count": len(result.get("result", [])), + }, default=str) + except Exception as e: + frappe.log_error(f"ERPNext Report Error [{report_name}]: {e}", "ERPNext Reports Tool") + return _error(str(e)) + + +def handle_list_reports(**kwargs) -> str: + """List available reports, optionally filtered by module or search keyword.""" + module = kwargs.get("module", "").strip() + search = kwargs.get("search", "").strip().lower() + + if module: + # case-insensitive match + matched = {k: v for k, v in REPORT_CATALOGUE.items() if k.lower() == module.lower()} + if search: + for k in matched: + matched[k] = [r for r in matched[k] if search in r.lower()] + + if not matched: + available = list(REPORT_CATALOGUE.keys()) + return json.dumps({"success": False, "error": f"Module '{module}' not found. Available: {available}"}, default=str) + return json.dumps({"success": True, "results": matched}, default=str) + + if search: + matched = {} + for mod, reports in REPORT_CATALOGUE.items(): + filtered = [r for r in reports if search in r.lower()] + if filtered: + matched[mod] = filtered + return json.dumps({"success": True, "results": matched}, default=str) + + return json.dumps({"success": True, "results": REPORT_CATALOGUE}, default=str) diff --git a/huf/ai/tools/github.py b/huf/ai/tools/github.py index 7a0b6154..db77c271 100644 --- a/huf/ai/tools/github.py +++ b/huf/ai/tools/github.py @@ -73,7 +73,7 @@ def handle_list_repos(**kwargs) -> str: error_msg = f"GitHub List Repos Error: {str(e)}" frappe.log_error(error_msg, "GitHub Tool") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": str(e)}) + return json.dumps({"success": False, "error": str(e)}, default=str) def handle_get_repo(**kwargs) -> str: @@ -82,7 +82,7 @@ def handle_get_repo(**kwargs) -> str: try: repo_name = kwargs.get("repo_name") if not repo_name: - return json.dumps({"success": False, "error": "repo_name is required"}) + return json.dumps({"success": False, "error": "repo_name is required"}, default=str) data = _make_github_request("GET", f"repos/{repo_name}") @@ -110,7 +110,7 @@ def handle_get_repo(**kwargs) -> str: error_msg = f"GitHub Get Repo Error: {str(e)}" frappe.log_error(error_msg, "GitHub Tool") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": str(e)}) + return json.dumps({"success": False, "error": str(e)}, default=str) def handle_create_issue(**kwargs) -> str: @@ -120,7 +120,7 @@ def handle_create_issue(**kwargs) -> str: repo_name = kwargs.get("repo_name") title = kwargs.get("title") if not all([repo_name, title]): - return json.dumps({"success": False, "error": "repo_name and title are required"}) + return json.dumps({"success": False, "error": "repo_name and title are required"}, default=str) body = kwargs.get("body") payload = {"title": title} @@ -146,7 +146,7 @@ def handle_create_issue(**kwargs) -> str: error_msg = f"GitHub Create Issue Error: {str(e)}" frappe.log_error(error_msg, "GitHub Tool") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": str(e)}) + return json.dumps({"success": False, "error": str(e)}, default=str) def handle_create_pull_request(**kwargs) -> str: @@ -159,7 +159,7 @@ def handle_create_pull_request(**kwargs) -> str: head = kwargs.get("head") base = kwargs.get("base") if not all([repo_name, title, body, head, base]): - return json.dumps({"success": False, "error": "repo_name, title, body, head, and base are required"}) + return json.dumps({"success": False, "error": "repo_name, title, body, head, and base are required"}, default=str) payload = { "title": title, @@ -189,7 +189,7 @@ def handle_create_pull_request(**kwargs) -> str: error_msg = f"GitHub Create PR Error: {str(e)}" frappe.log_error(error_msg, "GitHub Tool") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": str(e)}) + return json.dumps({"success": False, "error": str(e)}, default=str) def handle_get_file_content(**kwargs) -> str: @@ -199,7 +199,7 @@ def handle_get_file_content(**kwargs) -> str: repo_name = kwargs.get("repo_name") path = kwargs.get("path") if not all([repo_name, path]): - return json.dumps({"success": False, "error": "repo_name and path are required"}) + return json.dumps({"success": False, "error": "repo_name and path are required"}, default=str) data = _make_github_request("GET", f"repos/{repo_name}/contents/{path}") @@ -224,7 +224,7 @@ def handle_get_file_content(**kwargs) -> str: error_msg = f"GitHub Get File Content Error: {str(e)}" frappe.log_error(error_msg, "GitHub Tool") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": str(e)}) + return json.dumps({"success": False, "error": str(e)}, default=str) def handle_search_code(**kwargs) -> str: @@ -233,7 +233,7 @@ def handle_search_code(**kwargs) -> str: try: query = kwargs.get("query") if not query: - return json.dumps({"success": False, "error": "query is required"}) + return json.dumps({"success": False, "error": "query is required"}, default=str) data = _make_github_request("GET", "search/code", params={"q": query, "per_page": 30}) @@ -257,4 +257,4 @@ def handle_search_code(**kwargs) -> str: error_msg = f"GitHub Search Code Error: {str(e)}" frappe.log_error(error_msg, "GitHub Tool") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": str(e)}) + return json.dumps({"success": False, "error": str(e)}, default=str) diff --git a/huf/ai/tools/helpdesk.py b/huf/ai/tools/helpdesk.py new file mode 100644 index 00000000..5ea04e33 --- /dev/null +++ b/huf/ai/tools/helpdesk.py @@ -0,0 +1,320 @@ +""" +Helpdesk integration tools for Frappe Helpdesk. +Uses direct Frappe DocType APIs – no external HTTP calls. +""" + +import json +import frappe + + +def _helpdesk_installed(): + try: + return "helpdesk" in frappe.get_installed_apps() + except Exception: + return False + + +def _error(msg): + return json.dumps({"success": False, "error": msg}, default=str) + + +# --------------------------------------------------------------------------- +# Tickets +# --------------------------------------------------------------------------- + +def _handle_get_tickets(**kwargs) -> str: + """List tickets with filters (status, priority, assigned_to, team, search).""" + if not _helpdesk_installed(): + return _error("Frappe Helpdesk app is not installed.") + + try: + filters = {} + status = kwargs.get("status") + priority = kwargs.get("priority") + assigned_to = kwargs.get("assigned_to") + team = kwargs.get("team") + search = kwargs.get("search") + limit = int(kwargs.get("limit", 20)) + offset = int(kwargs.get("offset", 0)) + + if status: + filters["status"] = status + if priority: + filters["priority"] = priority + if team: + filters["agent_group"] = team + if assigned_to: + # _assign stores JSON list of assigned users + filters["_assign"] = ["like", f"%{assigned_to}%"] + + or_filters = [] + if search: + or_filters = [ + ["HD Ticket", "subject", "like", f"%{search}%"], + ["HD Ticket", "description", "like", f"%{search}%"], + ["HD Ticket", "raised_by", "like", f"%{search}%"], + ] + + fields = [ + "name", + "subject", + "raised_by", + "status", + "priority", + "agent_group", + "ticket_type", + "customer", + "contact", + "modified", + ] + + tickets = frappe.get_all( + "HD Ticket", + fields=fields, + filters=filters, + or_filters=or_filters or None, + limit=limit, + limit_start=offset, + order_by="modified desc", + ) + + return json.dumps({"success": True, "count": len(tickets), "results": tickets}, default=str) + except Exception as e: + frappe.log_error(f"Helpdesk Get Tickets Error: {e}", "Helpdesk Tool") + return _error(str(e)) + + +def _handle_get_ticket(**kwargs) -> str: + """Get single ticket details with comments.""" + if not _helpdesk_installed(): + return _error("Frappe Helpdesk app is not installed.") + + try: + ticket_id = kwargs.get("ticket_id") or kwargs.get("name") + if not ticket_id: + return _error("ticket_id or name is required") + if not frappe.db.exists("HD Ticket", ticket_id): + return _error(f"Ticket {ticket_id} not found") + + doc = frappe.get_doc("HD Ticket", ticket_id) + comments = frappe.get_all( + "HD Ticket Comment", + filters={"reference_ticket": ticket_id}, + fields=["name", "content", "commented_by", "creation", "is_pinned"], + order_by="creation desc", + limit=50, + ) + + result = doc.as_dict() + result["comments"] = comments + return json.dumps({"success": True, "results": result}, default=str) + except Exception as e: + frappe.log_error(f"Helpdesk Get Ticket Error: {e}", "Helpdesk Tool") + return _error(str(e)) + + +def _handle_create_ticket(**kwargs) -> str: + """Create a support ticket.""" + if not _helpdesk_installed(): + return _error("Frappe Helpdesk app is not installed.") + + try: + subject = kwargs.get("subject") + if not subject: + return _error("subject is required") + + doc = frappe.new_doc("HD Ticket") + doc.subject = subject + doc.description = kwargs.get("description", "") + doc.customer = kwargs.get("customer", "") + doc.priority = kwargs.get("priority", "") + doc.ticket_type = kwargs.get("ticket_type") or kwargs.get("type", "") + doc.agent_group = kwargs.get("team", "") + doc.raised_by = kwargs.get("raised_by") or kwargs.get("email", frappe.session.user) + doc.contact = kwargs.get("contact", "") + doc.insert(ignore_permissions=True) + + return json.dumps({"success": True, "results": {"name": doc.name, "subject": doc.subject}}, default=str) + except Exception as e: + frappe.log_error(f"Helpdesk Create Ticket Error: {e}", "Helpdesk Tool") + return _error(str(e)) + + +def _handle_update_ticket(**kwargs) -> str: + """Update ticket (status, priority, assigned_to, team).""" + if not _helpdesk_installed(): + return _error("Frappe Helpdesk app is not installed.") + + try: + ticket_id = kwargs.get("ticket_id") or kwargs.get("name") + if not ticket_id: + return _error("ticket_id or name is required") + if not frappe.db.exists("HD Ticket", ticket_id): + return _error(f"Ticket {ticket_id} not found") + + doc = frappe.get_doc("HD Ticket", ticket_id) + + if "status" in kwargs: + doc.status = kwargs["status"] + if "priority" in kwargs: + doc.priority = kwargs["priority"] + if "team" in kwargs: + doc.agent_group = kwargs["team"] + if "ticket_type" in kwargs: + doc.ticket_type = kwargs["ticket_type"] + if "type" in kwargs: + doc.ticket_type = kwargs["type"] + if "description" in kwargs: + doc.description = kwargs["description"] + if "resolution_details" in kwargs: + doc.resolution_details = kwargs["resolution_details"] + + doc.save(ignore_permissions=True) + + assigned_to = kwargs.get("assigned_to") + if assigned_to: + if not frappe.db.exists("HD Agent", assigned_to): + return _error(f"Agent {assigned_to} not found") + doc.assign_agent(assigned_to) + + return json.dumps({"success": True, "results": {"name": doc.name, "subject": doc.subject}}, default=str) + except Exception as e: + frappe.log_error(f"Helpdesk Update Ticket Error: {e}", "Helpdesk Tool") + return _error(str(e)) + + +def _handle_add_comment(**kwargs) -> str: + """Add a comment/reply to a ticket.""" + if not _helpdesk_installed(): + return _error("Frappe Helpdesk app is not installed.") + + try: + ticket_id = kwargs.get("ticket_id") + content = kwargs.get("content") + if not all([ticket_id, content]): + return _error("ticket_id and content are required") + if not frappe.db.exists("HD Ticket", ticket_id): + return _error(f"Ticket {ticket_id} not found") + + comment = frappe.new_doc("HD Ticket Comment") + comment.reference_ticket = ticket_id + comment.content = content + comment.commented_by = kwargs.get("commented_by", frappe.session.user) + comment.insert(ignore_permissions=True) + + return json.dumps({"success": True, "results": {"name": comment.name}}, default=str) + except Exception as e: + frappe.log_error(f"Helpdesk Add Comment Error: {e}", "Helpdesk Tool") + return _error(str(e)) + + +# --------------------------------------------------------------------------- +# Agents & Teams +# --------------------------------------------------------------------------- + +def _handle_get_agents(**kwargs) -> str: + """List helpdesk agents.""" + if not _helpdesk_installed(): + return _error("Frappe Helpdesk app is not installed.") + + try: + limit = int(kwargs.get("limit", 50)) + offset = int(kwargs.get("offset", 0)) + filters = {} + is_active = kwargs.get("is_active") + if is_active is not None: + filters["is_active"] = int(is_active) + + search = kwargs.get("search") + or_filters = [] + if search: + or_filters = [ + ["HD Agent", "agent_name", "like", f"%{search}%"], + ["HD Agent", "user", "like", f"%{search}%"], + ] + + agents = frappe.get_all( + "HD Agent", + fields=["name", "agent_name", "user", "is_active"], + filters=filters, + or_filters=or_filters or None, + limit=limit, + limit_start=offset, + order_by="modified desc", + ) + return json.dumps({"success": True, "count": len(agents), "results": agents}, default=str) + except Exception as e: + frappe.log_error(f"Helpdesk Get Agents Error: {e}", "Helpdesk Tool") + return _error(str(e)) + + +def _handle_get_teams(**kwargs) -> str: + """List helpdesk teams.""" + if not _helpdesk_installed(): + return _error("Frappe Helpdesk app is not installed.") + + try: + limit = int(kwargs.get("limit", 50)) + offset = int(kwargs.get("offset", 0)) + search = kwargs.get("search") + or_filters = [] + if search: + or_filters = [ + ["HD Team", "team_name", "like", f"%{search}%"], + ] + + teams = frappe.get_all( + "HD Team", + fields=["name", "team_name", "ignore_restrictions", "assignment_rule"], + or_filters=or_filters or None, + limit=limit, + limit_start=offset, + order_by="modified desc", + ) + return json.dumps({"success": True, "count": len(teams), "results": teams}, default=str) + except Exception as e: + frappe.log_error(f"Helpdesk Get Teams Error: {e}", "Helpdesk Tool") + return _error(str(e)) + + +def _handle_assign_ticket(**kwargs) -> str: + """Assign ticket to an agent.""" + if not _helpdesk_installed(): + return _error("Frappe Helpdesk app is not installed.") + + try: + ticket_id = kwargs.get("ticket_id") + agent_id = kwargs.get("agent_id") or frappe.session.user + if not ticket_id: + return _error("ticket_id is required") + if not frappe.db.exists("HD Ticket", ticket_id): + return _error(f"Ticket {ticket_id} not found") + if not frappe.db.exists("HD Agent", agent_id): + return _error(f"Agent {agent_id} not found") + + doc = frappe.get_doc("HD Ticket", ticket_id) + doc.assign_agent(agent_id) + + return json.dumps({"success": True, "results": {"name": doc.name, "assigned_to": agent_id}}, default=str) + except Exception as e: + frappe.log_error(f"Helpdesk Assign Ticket Error: {e}", "Helpdesk Tool") + return _error(str(e)) + + +def handle_action(**kwargs) -> str: + action = kwargs.get("action", "").strip().lower() + dispatch = { + "list_tickets": _handle_get_tickets, + "get_ticket": _handle_get_ticket, + "create_ticket": _handle_create_ticket, + "update_ticket": _handle_update_ticket, + "add_comment": _handle_add_comment, + "list_agents": _handle_get_agents, + "list_teams": _handle_get_teams, + "assign_ticket": _handle_assign_ticket, + } + handler = dispatch.get(action) + if not handler: + valid = ", ".join(sorted(dispatch.keys())) + return json.dumps({"success": False, "error": f"Unknown action '{action}'. Valid: {valid}"}, default=str) + return handler(**kwargs) diff --git a/huf/ai/tools/raven.py b/huf/ai/tools/raven.py new file mode 100644 index 00000000..3a9d47e6 --- /dev/null +++ b/huf/ai/tools/raven.py @@ -0,0 +1,315 @@ +""" +Raven integration tools for Frappe Raven. +Uses direct Frappe DocType APIs – no external HTTP calls. +""" + +import json +import frappe + + +def _raven_installed(): + try: + return "raven" in frappe.get_installed_apps() + except Exception: + return False + + +def _error(msg): + return json.dumps({"success": False, "error": msg}, default=str) + + +def _resolve_channel_id(channel_id=None, channel_name=None): + if channel_id and frappe.db.exists("Raven Channel", channel_id): + return channel_id + if channel_name: + cid = frappe.db.get_value("Raven Channel", {"channel_name": channel_name}, "name") + if cid: + return cid + + # Fallback: Raven often slugifies channel names (e.g. "Project Alpha" -> "project-alpha") + slugified = channel_name.lower().replace(" ", "-") + cid = frappe.db.get_value("Raven Channel", {"channel_name": slugified}, "name") + if cid: + return cid + + # Final fallback: fuzzy like match + search_term = f"%{channel_name.replace(' ', '%')}%" + channels = frappe.get_all("Raven Channel", filters={"channel_name": ("like", search_term)}, limit=1) + if channels: + return channels[0].name + + return None + + +# --------------------------------------------------------------------------- +# Messages +# --------------------------------------------------------------------------- + +def _handle_send_message(**kwargs) -> str: + """Send a message to a Raven channel.""" + if not _raven_installed(): + return _error("Frappe Raven app is not installed.") + + try: + channel_id = _resolve_channel_id( + kwargs.get("channel_id"), kwargs.get("channel_name") + ) + text = kwargs.get("text") + if not channel_id: + return _error("channel_id or channel_name is required and must exist") + if not text: + return _error("text is required") + + doc = frappe.new_doc("Raven Message") + doc.channel_id = channel_id + doc.text = text + doc.message_type = kwargs.get("message_type", "Text") + doc.insert(ignore_permissions=True) + + return json.dumps({"success": True, "results": {"name": doc.name, "channel_id": channel_id}}, default=str) + except Exception as e: + frappe.log_error(f"Raven Send Message Error: {e}", "Raven Tool") + return _error(str(e)) + + +def _handle_get_messages(**kwargs) -> str: + """Get recent messages from a channel.""" + if not _raven_installed(): + return _error("Frappe Raven app is not installed.") + + try: + channel_id = _resolve_channel_id( + kwargs.get("channel_id"), kwargs.get("channel_name") + ) + if not channel_id: + return _error("channel_id or channel_name is required and must exist") + + limit = int(kwargs.get("limit", 50)) + before_message_id = kwargs.get("before_message_id") + + filters = {"channel_id": channel_id} + if before_message_id: + creation = frappe.db.get_value("Raven Message", before_message_id, "creation") + if creation: + filters["creation"] = ["<", creation] + + messages = frappe.get_all( + "Raven Message", + fields=[ + "name", + "text", + "content", + "message_type", + "owner", + "creation", + "channel_id", + "file", + "is_reply", + "linked_message", + "is_edited", + "is_forwarded", + "is_thread", + ], + filters=filters, + limit=limit, + order_by="creation desc", + ) + + return json.dumps({"success": True, "count": len(messages), "results": messages}, default=str) + except Exception as e: + frappe.log_error(f"Raven Get Messages Error: {e}", "Raven Tool") + return _error(str(e)) + + +# --------------------------------------------------------------------------- +# Channels +# --------------------------------------------------------------------------- + +def _handle_list_channels(**kwargs) -> str: + """List all channels (optionally filter by type: public/private/open).""" + if not _raven_installed(): + return _error("Frappe Raven app is not installed.") + + try: + filters = {"is_archived": 0} + channel_type = kwargs.get("channel_type") or kwargs.get("type") + if channel_type: + ctype = channel_type.capitalize() + if ctype in {"Private", "Public", "Open"}: + filters["type"] = ctype + + limit = int(kwargs.get("limit", 50)) + offset = int(kwargs.get("offset", 0)) + + channels = frappe.get_all( + "Raven Channel", + fields=[ + "name", + "channel_name", + "type", + "channel_description", + "is_direct_message", + "is_archived", + "workspace", + "creation", + "modified", + ], + filters=filters, + limit=limit, + limit_start=offset, + order_by="modified desc", + ) + + return json.dumps({"success": True, "count": len(channels), "results": channels}, default=str) + except Exception as e: + frappe.log_error(f"Raven List Channels Error: {e}", "Raven Tool") + return _error(str(e)) + + +def _handle_get_channel_members(**kwargs) -> str: + """Get members of a channel.""" + if not _raven_installed(): + return _error("Frappe Raven app is not installed.") + + try: + channel_id = _resolve_channel_id( + kwargs.get("channel_id"), kwargs.get("channel_name") + ) + if not channel_id: + return _error("channel_id or channel_name is required and must exist") + + limit = int(kwargs.get("limit", 100)) + offset = int(kwargs.get("offset", 0)) + + members = frappe.get_all( + "Raven Channel Member", + filters={"channel_id": channel_id}, + fields=["name", "user_id", "is_admin", "last_visit", "allow_notifications"], + limit=limit, + limit_start=offset, + order_by="creation asc", + ) + + return json.dumps({"success": True, "count": len(members), "results": members}, default=str) + except Exception as e: + frappe.log_error(f"Raven Get Channel Members Error: {e}", "Raven Tool") + return _error(str(e)) + + +def _handle_create_channel(**kwargs) -> str: + """Create a new channel.""" + if not _raven_installed(): + return _error("Frappe Raven app is not installed.") + + try: + channel_name = kwargs.get("channel_name") + channel_type = kwargs.get("type", "Public") + if not channel_name: + return _error("channel_name is required") + + valid_types = {"Private", "Public", "Open"} + ctype = channel_type.capitalize() + if ctype not in valid_types: + return _error("type must be one of: Private, Public, Open") + + workspace = kwargs.get("workspace") + if not workspace: + workspaces = frappe.get_all("Raven Workspace", fields=["name"], limit=1) + if workspaces: + workspace = workspaces[0].name + + if not workspace: + return _error("No Raven workspace found. Please specify workspace.") + + doc = frappe.new_doc("Raven Channel") + doc.channel_name = channel_name + doc.type = ctype + doc.workspace = workspace + doc.channel_description = kwargs.get("channel_description", "") + doc.insert(ignore_permissions=True) + + members = kwargs.get("members", []) + if isinstance(members, str): + try: + members = json.loads(members) + except Exception: + members = [] + + for user_id in members: + if not frappe.db.exists("Raven Channel Member", {"channel_id": doc.name, "user_id": user_id}): + member = frappe.new_doc("Raven Channel Member") + member.channel_id = doc.name + member.user_id = user_id + member.insert(ignore_permissions=True) + + return json.dumps({"success": True, "results": {"name": doc.name, "channel_name": doc.channel_name}}, default=str) + except Exception as e: + frappe.log_error(f"Raven Create Channel Error: {e}", "Raven Tool") + return _error(str(e)) + + +# --------------------------------------------------------------------------- +# Search +# --------------------------------------------------------------------------- + +def _handle_search_messages(**kwargs) -> str: + """Search messages across channels or within a specific channel.""" + if not _raven_installed(): + return _error("Frappe Raven app is not installed.") + + try: + query = kwargs.get("query") + if not query: + return _error("query is required") + + channel_id = _resolve_channel_id( + kwargs.get("channel_id"), kwargs.get("channel_name") + ) + limit = int(kwargs.get("limit", 50)) + offset = int(kwargs.get("offset", 0)) + + filters = {"text": ["like", f"%{query}%"]} + if channel_id: + filters["channel_id"] = channel_id + + messages = frappe.get_all( + "Raven Message", + fields=[ + "name", + "text", + "content", + "message_type", + "owner", + "creation", + "channel_id", + "file", + "is_reply", + "linked_message", + ], + filters=filters, + limit=limit, + limit_start=offset, + order_by="creation desc", + ) + + return json.dumps({"success": True, "count": len(messages), "results": messages}, default=str) + except Exception as e: + frappe.log_error(f"Raven Search Messages Error: {e}", "Raven Tool") + return _error(str(e)) + + +def handle_action(**kwargs) -> str: + action = kwargs.get("action", "").strip().lower() + dispatch = { + "send_message": _handle_send_message, + "get_messages": _handle_get_messages, + "list_channels": _handle_list_channels, + "get_members": _handle_get_channel_members, + "create_channel": _handle_create_channel, + "search_messages": _handle_search_messages, + } + handler = dispatch.get(action) + if not handler: + valid = ", ".join(sorted(dispatch.keys())) + return json.dumps({"success": False, "error": f"Unknown action '{action}'. Valid: {valid}"}, default=str) + return handler(**kwargs) diff --git a/huf/ai/tools/recipient.py b/huf/ai/tools/recipient.py index 39721d96..fdce1bd4 100644 --- a/huf/ai/tools/recipient.py +++ b/huf/ai/tools/recipient.py @@ -88,4 +88,4 @@ def handle_get_recipient(**kwargs) -> str: except Exception as e: frappe.log_error(f"get_integration_recipient error: {str(e)}", "Integration Recipient Tool") - return json.dumps({"success": False, "error": str(e)}) + return json.dumps({"success": False, "error": str(e)}, default=str) diff --git a/huf/ai/tools/slack.py b/huf/ai/tools/slack.py index 3c1f87d9..adbc1999 100644 --- a/huf/ai/tools/slack.py +++ b/huf/ai/tools/slack.py @@ -28,7 +28,7 @@ def handle_send_message(**kwargs) -> str: channel = kwargs.get("channel") text = kwargs.get("text") if not all([channel, text]): - return json.dumps({"success": False, "error": "channel and text are required"}) + return json.dumps({"success": False, "error": "channel and text are required"}, default=str) headers = _get_slack_headers() payload = {"channel": channel, "text": text, "mrkdwn": True} @@ -45,14 +45,14 @@ def handle_send_message(**kwargs) -> str: if not data.get("ok"): error_msg = data.get("error", "Unknown error") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": error_msg}) + return json.dumps({"success": False, "error": error_msg}, default=str) - return json.dumps({"success": True, "results": data}) + return json.dumps({"success": True, "results": data}, default=str) except Exception as e: error_msg = f"Slack Send Message Error: {str(e)}" frappe.log_error(error_msg, "Slack Tool") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": str(e)}) + return json.dumps({"success": False, "error": str(e)}, default=str) def handle_send_message_thread(**kwargs) -> str: @@ -63,7 +63,7 @@ def handle_send_message_thread(**kwargs) -> str: text = kwargs.get("text") thread_ts = kwargs.get("thread_ts") if not all([channel, text, thread_ts]): - return json.dumps({"success": False, "error": "channel, text, and thread_ts are required"}) + return json.dumps({"success": False, "error": "channel, text, and thread_ts are required"}, default=str) headers = _get_slack_headers() payload = {"channel": channel, "text": text, "thread_ts": thread_ts, "mrkdwn": True} @@ -80,14 +80,14 @@ def handle_send_message_thread(**kwargs) -> str: if not data.get("ok"): error_msg = data.get("error", "Unknown error") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": error_msg}) + return json.dumps({"success": False, "error": error_msg}, default=str) - return json.dumps({"success": True, "results": data}) + return json.dumps({"success": True, "results": data}, default=str) except Exception as e: error_msg = f"Slack Thread Reply Error: {str(e)}" frappe.log_error(error_msg, "Slack Tool") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": str(e)}) + return json.dumps({"success": False, "error": str(e)}, default=str) def handle_list_channels(**kwargs) -> str: @@ -108,7 +108,7 @@ def handle_list_channels(**kwargs) -> str: if not data.get("ok"): error_msg = data.get("error", "Unknown error") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": error_msg}) + return json.dumps({"success": False, "error": error_msg}, default=str) channels = [{"id": ch.get("id"), "name": ch.get("name"), "is_private": ch.get("is_private", False)} for ch in data.get("channels", [])] @@ -122,7 +122,7 @@ def handle_list_channels(**kwargs) -> str: error_msg = f"Slack List Channels Error: {str(e)}" frappe.log_error(error_msg, "Slack Tool") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": str(e)}) + return json.dumps({"success": False, "error": str(e)}, default=str) def handle_get_channel_history(**kwargs) -> str: @@ -131,7 +131,7 @@ def handle_get_channel_history(**kwargs) -> str: try: channel = kwargs.get("channel") if not channel: - return json.dumps({"success": False, "error": "channel is required"}) + return json.dumps({"success": False, "error": "channel is required"}, default=str) limit = int(kwargs.get("limit", 100)) headers = _get_slack_headers() @@ -148,7 +148,7 @@ def handle_get_channel_history(**kwargs) -> str: if not data.get("ok"): error_msg = data.get("error", "Unknown error") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": error_msg}) + return json.dumps({"success": False, "error": error_msg}, default=str) messages = [{"ts": msg.get("ts"), "text": msg.get("text", ""), "user": msg.get("user", "bot" if msg.get("bot_id") else "unknown")} for msg in data.get("messages", [])] @@ -162,7 +162,7 @@ def handle_get_channel_history(**kwargs) -> str: error_msg = f"Slack Get History Error: {str(e)}" frappe.log_error(error_msg, "Slack Tool") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": str(e)}) + return json.dumps({"success": False, "error": str(e)}, default=str) def handle_search_messages(**kwargs) -> str: @@ -171,7 +171,7 @@ def handle_search_messages(**kwargs) -> str: try: query = kwargs.get("query") if not query: - return json.dumps({"success": False, "error": "query is required"}) + return json.dumps({"success": False, "error": "query is required"}, default=str) limit = int(kwargs.get("limit", 20)) headers = _get_slack_headers() @@ -188,7 +188,7 @@ def handle_search_messages(**kwargs) -> str: if not data.get("ok"): error_msg = data.get("error", "Unknown error") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": error_msg}) + return json.dumps({"success": False, "error": error_msg}, default=str) matches = data.get("messages", {}).get("matches", []) results = [{"text": msg.get("text", ""), "user": msg.get("username", msg.get("user", "unknown")), "channel": msg.get("channel", {}).get("name", "unknown"), "permalink": msg.get("permalink")} @@ -203,7 +203,7 @@ def handle_search_messages(**kwargs) -> str: error_msg = f"Slack Search Error: {str(e)}" frappe.log_error(error_msg, "Slack Tool") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": str(e)}) + return json.dumps({"success": False, "error": str(e)}, default=str) def handle_list_users(**kwargs) -> str: @@ -225,7 +225,7 @@ def handle_list_users(**kwargs) -> str: if not data.get("ok"): error_msg = data.get("error", "Unknown error") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": error_msg}) + return json.dumps({"success": False, "error": error_msg}, default=str) users = [{"id": user.get("id"), "name": user.get("name"), "real_name": user.get("real_name", ""), "is_bot": user.get("is_bot", False)} for user in data.get("members", []) @@ -240,4 +240,4 @@ def handle_list_users(**kwargs) -> str: error_msg = f"Slack List Users Error: {str(e)}" frappe.log_error(error_msg, "Slack Tool") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": str(e)}) + return json.dumps({"success": False, "error": str(e)}, default=str) diff --git a/huf/ai/tools/telegram.py b/huf/ai/tools/telegram.py index 9c0ebdb5..46688ac4 100644 --- a/huf/ai/tools/telegram.py +++ b/huf/ai/tools/telegram.py @@ -16,14 +16,14 @@ def handle_send_message(**kwargs) -> str: chat_id = kwargs.get("chat_id") message = kwargs.get("message") if not all([chat_id, message]): - return json.dumps({"success": False, "error": "chat_id and message are required"}) + return json.dumps({"success": False, "error": "chat_id and message are required"}, default=str) # Get token from Integration Settings token = get_credential(service_name, "token") if not token: error_msg = "Telegram bot token not configured" update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": error_msg}) + return json.dumps({"success": False, "error": error_msg}, default=str) url = f"https://api.telegram.org/bot{token}/sendMessage" payload = { @@ -43,4 +43,4 @@ def handle_send_message(**kwargs) -> str: error_msg = f"Telegram Send Message Error: {str(e)}" frappe.log_error(error_msg, "Telegram Tool") update_last_error(service_name, error_msg) - return json.dumps({"success": False, "error": str(e)}) + return json.dumps({"success": False, "error": str(e)}, default=str)