agno 2.1.2__py3-none-any.whl → 2.3.13__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agno/agent/agent.py +5540 -2273
- agno/api/api.py +2 -0
- agno/api/os.py +1 -1
- agno/compression/__init__.py +3 -0
- agno/compression/manager.py +247 -0
- agno/culture/__init__.py +3 -0
- agno/culture/manager.py +956 -0
- agno/db/async_postgres/__init__.py +3 -0
- agno/db/base.py +689 -6
- agno/db/dynamo/dynamo.py +933 -37
- agno/db/dynamo/schemas.py +174 -10
- agno/db/dynamo/utils.py +63 -4
- agno/db/firestore/firestore.py +831 -9
- agno/db/firestore/schemas.py +51 -0
- agno/db/firestore/utils.py +102 -4
- agno/db/gcs_json/gcs_json_db.py +660 -12
- agno/db/gcs_json/utils.py +60 -26
- agno/db/in_memory/in_memory_db.py +287 -14
- agno/db/in_memory/utils.py +60 -2
- agno/db/json/json_db.py +590 -14
- agno/db/json/utils.py +60 -26
- agno/db/migrations/manager.py +199 -0
- agno/db/migrations/v1_to_v2.py +43 -13
- agno/db/migrations/versions/__init__.py +0 -0
- agno/db/migrations/versions/v2_3_0.py +938 -0
- agno/db/mongo/__init__.py +15 -1
- agno/db/mongo/async_mongo.py +2760 -0
- agno/db/mongo/mongo.py +879 -11
- agno/db/mongo/schemas.py +42 -0
- agno/db/mongo/utils.py +80 -8
- agno/db/mysql/__init__.py +2 -1
- agno/db/mysql/async_mysql.py +2912 -0
- agno/db/mysql/mysql.py +946 -68
- agno/db/mysql/schemas.py +72 -10
- agno/db/mysql/utils.py +198 -7
- agno/db/postgres/__init__.py +2 -1
- agno/db/postgres/async_postgres.py +2579 -0
- agno/db/postgres/postgres.py +942 -57
- agno/db/postgres/schemas.py +81 -18
- agno/db/postgres/utils.py +164 -2
- agno/db/redis/redis.py +671 -7
- agno/db/redis/schemas.py +50 -0
- agno/db/redis/utils.py +65 -7
- agno/db/schemas/__init__.py +2 -1
- agno/db/schemas/culture.py +120 -0
- agno/db/schemas/evals.py +1 -0
- agno/db/schemas/memory.py +17 -2
- agno/db/singlestore/schemas.py +63 -0
- agno/db/singlestore/singlestore.py +949 -83
- agno/db/singlestore/utils.py +60 -2
- agno/db/sqlite/__init__.py +2 -1
- agno/db/sqlite/async_sqlite.py +2911 -0
- agno/db/sqlite/schemas.py +62 -0
- agno/db/sqlite/sqlite.py +965 -46
- agno/db/sqlite/utils.py +169 -8
- agno/db/surrealdb/__init__.py +3 -0
- agno/db/surrealdb/metrics.py +292 -0
- agno/db/surrealdb/models.py +334 -0
- agno/db/surrealdb/queries.py +71 -0
- agno/db/surrealdb/surrealdb.py +1908 -0
- agno/db/surrealdb/utils.py +147 -0
- agno/db/utils.py +2 -0
- agno/eval/__init__.py +10 -0
- agno/eval/accuracy.py +75 -55
- agno/eval/agent_as_judge.py +861 -0
- agno/eval/base.py +29 -0
- agno/eval/performance.py +16 -7
- agno/eval/reliability.py +28 -16
- agno/eval/utils.py +35 -17
- agno/exceptions.py +27 -2
- agno/filters.py +354 -0
- agno/guardrails/prompt_injection.py +1 -0
- agno/hooks/__init__.py +3 -0
- agno/hooks/decorator.py +164 -0
- agno/integrations/discord/client.py +1 -1
- agno/knowledge/chunking/agentic.py +13 -10
- agno/knowledge/chunking/fixed.py +4 -1
- agno/knowledge/chunking/semantic.py +9 -4
- agno/knowledge/chunking/strategy.py +59 -15
- agno/knowledge/embedder/fastembed.py +1 -1
- agno/knowledge/embedder/nebius.py +1 -1
- agno/knowledge/embedder/ollama.py +8 -0
- agno/knowledge/embedder/openai.py +8 -8
- agno/knowledge/embedder/sentence_transformer.py +6 -2
- agno/knowledge/embedder/vllm.py +262 -0
- agno/knowledge/knowledge.py +1618 -318
- agno/knowledge/reader/base.py +6 -2
- agno/knowledge/reader/csv_reader.py +8 -10
- agno/knowledge/reader/docx_reader.py +5 -6
- agno/knowledge/reader/field_labeled_csv_reader.py +16 -20
- agno/knowledge/reader/json_reader.py +5 -4
- agno/knowledge/reader/markdown_reader.py +8 -8
- agno/knowledge/reader/pdf_reader.py +17 -19
- agno/knowledge/reader/pptx_reader.py +101 -0
- agno/knowledge/reader/reader_factory.py +32 -3
- agno/knowledge/reader/s3_reader.py +3 -3
- agno/knowledge/reader/tavily_reader.py +193 -0
- agno/knowledge/reader/text_reader.py +22 -10
- agno/knowledge/reader/web_search_reader.py +1 -48
- agno/knowledge/reader/website_reader.py +10 -10
- agno/knowledge/reader/wikipedia_reader.py +33 -1
- agno/knowledge/types.py +1 -0
- agno/knowledge/utils.py +72 -7
- agno/media.py +22 -6
- agno/memory/__init__.py +14 -1
- agno/memory/manager.py +544 -83
- agno/memory/strategies/__init__.py +15 -0
- agno/memory/strategies/base.py +66 -0
- agno/memory/strategies/summarize.py +196 -0
- agno/memory/strategies/types.py +37 -0
- agno/models/aimlapi/aimlapi.py +17 -0
- agno/models/anthropic/claude.py +515 -40
- agno/models/aws/bedrock.py +102 -21
- agno/models/aws/claude.py +131 -274
- agno/models/azure/ai_foundry.py +41 -19
- agno/models/azure/openai_chat.py +39 -8
- agno/models/base.py +1249 -525
- agno/models/cerebras/cerebras.py +91 -21
- agno/models/cerebras/cerebras_openai.py +21 -2
- agno/models/cohere/chat.py +40 -6
- agno/models/cometapi/cometapi.py +18 -1
- agno/models/dashscope/dashscope.py +2 -3
- agno/models/deepinfra/deepinfra.py +18 -1
- agno/models/deepseek/deepseek.py +69 -3
- agno/models/fireworks/fireworks.py +18 -1
- agno/models/google/gemini.py +877 -80
- agno/models/google/utils.py +22 -0
- agno/models/groq/groq.py +51 -18
- agno/models/huggingface/huggingface.py +17 -6
- agno/models/ibm/watsonx.py +16 -6
- agno/models/internlm/internlm.py +18 -1
- agno/models/langdb/langdb.py +13 -1
- agno/models/litellm/chat.py +44 -9
- agno/models/litellm/litellm_openai.py +18 -1
- agno/models/message.py +28 -5
- agno/models/meta/llama.py +47 -14
- agno/models/meta/llama_openai.py +22 -17
- agno/models/mistral/mistral.py +8 -4
- agno/models/nebius/nebius.py +6 -7
- agno/models/nvidia/nvidia.py +20 -3
- agno/models/ollama/chat.py +24 -8
- agno/models/openai/chat.py +104 -29
- agno/models/openai/responses.py +101 -81
- agno/models/openrouter/openrouter.py +60 -3
- agno/models/perplexity/perplexity.py +17 -1
- agno/models/portkey/portkey.py +7 -6
- agno/models/requesty/requesty.py +24 -4
- agno/models/response.py +73 -2
- agno/models/sambanova/sambanova.py +20 -3
- agno/models/siliconflow/siliconflow.py +19 -2
- agno/models/together/together.py +20 -3
- agno/models/utils.py +254 -8
- agno/models/vercel/v0.py +20 -3
- agno/models/vertexai/__init__.py +0 -0
- agno/models/vertexai/claude.py +190 -0
- agno/models/vllm/vllm.py +19 -14
- agno/models/xai/xai.py +19 -2
- agno/os/app.py +549 -152
- agno/os/auth.py +190 -3
- agno/os/config.py +23 -0
- agno/os/interfaces/a2a/router.py +8 -11
- agno/os/interfaces/a2a/utils.py +1 -1
- agno/os/interfaces/agui/router.py +18 -3
- agno/os/interfaces/agui/utils.py +152 -39
- agno/os/interfaces/slack/router.py +55 -37
- agno/os/interfaces/slack/slack.py +9 -1
- agno/os/interfaces/whatsapp/router.py +0 -1
- agno/os/interfaces/whatsapp/security.py +3 -1
- agno/os/mcp.py +110 -52
- agno/os/middleware/__init__.py +2 -0
- agno/os/middleware/jwt.py +676 -112
- agno/os/router.py +40 -1478
- agno/os/routers/agents/__init__.py +3 -0
- agno/os/routers/agents/router.py +599 -0
- agno/os/routers/agents/schema.py +261 -0
- agno/os/routers/evals/evals.py +96 -39
- agno/os/routers/evals/schemas.py +65 -33
- agno/os/routers/evals/utils.py +80 -10
- agno/os/routers/health.py +10 -4
- agno/os/routers/knowledge/knowledge.py +196 -38
- agno/os/routers/knowledge/schemas.py +82 -22
- agno/os/routers/memory/memory.py +279 -52
- agno/os/routers/memory/schemas.py +46 -17
- agno/os/routers/metrics/metrics.py +20 -8
- agno/os/routers/metrics/schemas.py +16 -16
- agno/os/routers/session/session.py +462 -34
- agno/os/routers/teams/__init__.py +3 -0
- agno/os/routers/teams/router.py +512 -0
- agno/os/routers/teams/schema.py +257 -0
- agno/os/routers/traces/__init__.py +3 -0
- agno/os/routers/traces/schemas.py +414 -0
- agno/os/routers/traces/traces.py +499 -0
- agno/os/routers/workflows/__init__.py +3 -0
- agno/os/routers/workflows/router.py +624 -0
- agno/os/routers/workflows/schema.py +75 -0
- agno/os/schema.py +256 -693
- agno/os/scopes.py +469 -0
- agno/os/utils.py +514 -36
- agno/reasoning/anthropic.py +80 -0
- agno/reasoning/gemini.py +73 -0
- agno/reasoning/openai.py +5 -0
- agno/reasoning/vertexai.py +76 -0
- agno/run/__init__.py +6 -0
- agno/run/agent.py +155 -32
- agno/run/base.py +55 -3
- agno/run/requirement.py +181 -0
- agno/run/team.py +125 -38
- agno/run/workflow.py +72 -18
- agno/session/agent.py +102 -89
- agno/session/summary.py +56 -15
- agno/session/team.py +164 -90
- agno/session/workflow.py +405 -40
- agno/table.py +10 -0
- agno/team/team.py +3974 -1903
- agno/tools/dalle.py +2 -4
- agno/tools/eleven_labs.py +23 -25
- agno/tools/exa.py +21 -16
- agno/tools/file.py +153 -23
- agno/tools/file_generation.py +16 -10
- agno/tools/firecrawl.py +15 -7
- agno/tools/function.py +193 -38
- agno/tools/gmail.py +238 -14
- agno/tools/google_drive.py +271 -0
- agno/tools/googlecalendar.py +36 -8
- agno/tools/googlesheets.py +20 -5
- agno/tools/jira.py +20 -0
- agno/tools/mcp/__init__.py +10 -0
- agno/tools/mcp/mcp.py +331 -0
- agno/tools/mcp/multi_mcp.py +347 -0
- agno/tools/mcp/params.py +24 -0
- agno/tools/mcp_toolbox.py +3 -3
- agno/tools/models/nebius.py +5 -5
- agno/tools/models_labs.py +20 -10
- agno/tools/nano_banana.py +151 -0
- agno/tools/notion.py +204 -0
- agno/tools/parallel.py +314 -0
- agno/tools/postgres.py +76 -36
- agno/tools/redshift.py +406 -0
- agno/tools/scrapegraph.py +1 -1
- agno/tools/shopify.py +1519 -0
- agno/tools/slack.py +18 -3
- agno/tools/spotify.py +919 -0
- agno/tools/tavily.py +146 -0
- agno/tools/toolkit.py +25 -0
- agno/tools/workflow.py +8 -1
- agno/tools/yfinance.py +12 -11
- agno/tracing/__init__.py +12 -0
- agno/tracing/exporter.py +157 -0
- agno/tracing/schemas.py +276 -0
- agno/tracing/setup.py +111 -0
- agno/utils/agent.py +938 -0
- agno/utils/cryptography.py +22 -0
- agno/utils/dttm.py +33 -0
- agno/utils/events.py +151 -3
- agno/utils/gemini.py +15 -5
- agno/utils/hooks.py +118 -4
- agno/utils/http.py +113 -2
- agno/utils/knowledge.py +12 -5
- agno/utils/log.py +1 -0
- agno/utils/mcp.py +92 -2
- agno/utils/media.py +187 -1
- agno/utils/merge_dict.py +3 -3
- agno/utils/message.py +60 -0
- agno/utils/models/ai_foundry.py +9 -2
- agno/utils/models/claude.py +49 -14
- agno/utils/models/cohere.py +9 -2
- agno/utils/models/llama.py +9 -2
- agno/utils/models/mistral.py +4 -2
- agno/utils/print_response/agent.py +109 -16
- agno/utils/print_response/team.py +223 -30
- agno/utils/print_response/workflow.py +251 -34
- agno/utils/streamlit.py +1 -1
- agno/utils/team.py +98 -9
- agno/utils/tokens.py +657 -0
- agno/vectordb/base.py +39 -7
- agno/vectordb/cassandra/cassandra.py +21 -5
- agno/vectordb/chroma/chromadb.py +43 -12
- agno/vectordb/clickhouse/clickhousedb.py +21 -5
- agno/vectordb/couchbase/couchbase.py +29 -5
- agno/vectordb/lancedb/lance_db.py +92 -181
- agno/vectordb/langchaindb/langchaindb.py +24 -4
- agno/vectordb/lightrag/lightrag.py +17 -3
- agno/vectordb/llamaindex/llamaindexdb.py +25 -5
- agno/vectordb/milvus/milvus.py +50 -37
- agno/vectordb/mongodb/__init__.py +7 -1
- agno/vectordb/mongodb/mongodb.py +36 -30
- agno/vectordb/pgvector/pgvector.py +201 -77
- agno/vectordb/pineconedb/pineconedb.py +41 -23
- agno/vectordb/qdrant/qdrant.py +67 -54
- agno/vectordb/redis/__init__.py +9 -0
- agno/vectordb/redis/redisdb.py +682 -0
- agno/vectordb/singlestore/singlestore.py +50 -29
- agno/vectordb/surrealdb/surrealdb.py +31 -41
- agno/vectordb/upstashdb/upstashdb.py +34 -6
- agno/vectordb/weaviate/weaviate.py +53 -14
- agno/workflow/__init__.py +2 -0
- agno/workflow/agent.py +299 -0
- agno/workflow/condition.py +120 -18
- agno/workflow/loop.py +77 -10
- agno/workflow/parallel.py +231 -143
- agno/workflow/router.py +118 -17
- agno/workflow/step.py +609 -170
- agno/workflow/steps.py +73 -6
- agno/workflow/types.py +96 -21
- agno/workflow/workflow.py +2039 -262
- {agno-2.1.2.dist-info → agno-2.3.13.dist-info}/METADATA +201 -66
- agno-2.3.13.dist-info/RECORD +613 -0
- agno/tools/googlesearch.py +0 -98
- agno/tools/mcp.py +0 -679
- agno/tools/memori.py +0 -339
- agno-2.1.2.dist-info/RECORD +0 -543
- {agno-2.1.2.dist-info → agno-2.3.13.dist-info}/WHEEL +0 -0
- {agno-2.1.2.dist-info → agno-2.3.13.dist-info}/licenses/LICENSE +0 -0
- {agno-2.1.2.dist-info → agno-2.3.13.dist-info}/top_level.txt +0 -0
agno/tools/shopify.py
ADDED
|
@@ -0,0 +1,1519 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shopify Toolkit for Agno SDK
|
|
3
|
+
|
|
4
|
+
A toolkit for analyzing sales data, product performance, and customer insights using the Shopify Admin GraphQL API.
|
|
5
|
+
Requires a valid Shopify access token with appropriate scopes.
|
|
6
|
+
|
|
7
|
+
Required scopes:
|
|
8
|
+
- read_orders (for order and sales data)
|
|
9
|
+
- read_products (for product information)
|
|
10
|
+
- read_customers (for customer insights)
|
|
11
|
+
- read_analytics (for analytics data)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from collections import Counter
|
|
16
|
+
from datetime import datetime, timedelta
|
|
17
|
+
from itertools import combinations
|
|
18
|
+
from os import getenv
|
|
19
|
+
from typing import Any, Dict, List, Optional
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from agno.tools import Toolkit
|
|
24
|
+
from agno.utils.log import log_debug
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ShopifyTools(Toolkit):
|
|
28
|
+
"""
|
|
29
|
+
Shopify toolkit for analyzing sales data and product performance.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
shop_name: Your Shopify store name (e.g., 'my-store' from my-store.myshopify.com).
|
|
33
|
+
access_token: Shopify Admin API access token with required scopes.
|
|
34
|
+
api_version: Shopify API version (default: '2025-10').
|
|
35
|
+
timeout: Request timeout in seconds.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
shop_name: Optional[str] = None,
|
|
41
|
+
access_token: Optional[str] = None,
|
|
42
|
+
api_version: str = "2025-10",
|
|
43
|
+
timeout: int = 30,
|
|
44
|
+
**kwargs,
|
|
45
|
+
):
|
|
46
|
+
self.shop_name = shop_name or getenv("SHOPIFY_SHOP_NAME")
|
|
47
|
+
self.access_token = access_token or getenv("SHOPIFY_ACCESS_TOKEN")
|
|
48
|
+
self.api_version = api_version
|
|
49
|
+
self.timeout = timeout
|
|
50
|
+
self.base_url = f"https://{self.shop_name}.myshopify.com/admin/api/{self.api_version}/graphql.json"
|
|
51
|
+
|
|
52
|
+
tools: List[Any] = [
|
|
53
|
+
self.get_shop_info,
|
|
54
|
+
self.get_products,
|
|
55
|
+
self.get_orders,
|
|
56
|
+
self.get_top_selling_products,
|
|
57
|
+
self.get_products_bought_together,
|
|
58
|
+
self.get_sales_by_date_range,
|
|
59
|
+
self.get_order_analytics,
|
|
60
|
+
self.get_product_sales_breakdown,
|
|
61
|
+
self.get_customer_order_history,
|
|
62
|
+
self.get_inventory_levels,
|
|
63
|
+
self.get_low_stock_products,
|
|
64
|
+
self.get_sales_trends,
|
|
65
|
+
self.get_average_order_value,
|
|
66
|
+
self.get_repeat_customers,
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
super().__init__(name="shopify", tools=tools, **kwargs)
|
|
70
|
+
|
|
71
|
+
def _make_graphql_request(self, query: str, variables: Optional[Dict] = None) -> Dict:
|
|
72
|
+
"""Make an authenticated GraphQL request to the Shopify Admin API."""
|
|
73
|
+
headers = {
|
|
74
|
+
"X-Shopify-Access-Token": self.access_token or "",
|
|
75
|
+
"Content-Type": "application/json",
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
body: Dict[str, Any] = {"query": query}
|
|
79
|
+
if variables:
|
|
80
|
+
body["variables"] = variables
|
|
81
|
+
|
|
82
|
+
with httpx.Client(timeout=self.timeout) as client:
|
|
83
|
+
response = client.post(
|
|
84
|
+
self.base_url,
|
|
85
|
+
headers=headers,
|
|
86
|
+
json=body,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
result = response.json()
|
|
91
|
+
if "errors" in result:
|
|
92
|
+
return {"error": result["errors"]}
|
|
93
|
+
return result.get("data", {})
|
|
94
|
+
except json.JSONDecodeError:
|
|
95
|
+
return {"error": f"Failed to parse response: {response.text}"}
|
|
96
|
+
|
|
97
|
+
def get_shop_info(self) -> str:
|
|
98
|
+
"""Get basic information about the Shopify store.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
JSON string containing shop name, email, currency, and other details.
|
|
102
|
+
"""
|
|
103
|
+
log_debug("Fetching Shopify shop info")
|
|
104
|
+
|
|
105
|
+
query = """
|
|
106
|
+
query {
|
|
107
|
+
shop {
|
|
108
|
+
name
|
|
109
|
+
email
|
|
110
|
+
currencyCode
|
|
111
|
+
primaryDomain {
|
|
112
|
+
url
|
|
113
|
+
}
|
|
114
|
+
billingAddress {
|
|
115
|
+
country
|
|
116
|
+
city
|
|
117
|
+
}
|
|
118
|
+
plan {
|
|
119
|
+
displayName
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
result = self._make_graphql_request(query)
|
|
126
|
+
|
|
127
|
+
if "error" in result:
|
|
128
|
+
return json.dumps(result, indent=2)
|
|
129
|
+
|
|
130
|
+
return json.dumps(result.get("shop", {}), indent=2)
|
|
131
|
+
|
|
132
|
+
def get_products(
|
|
133
|
+
self,
|
|
134
|
+
max_results: int = 50,
|
|
135
|
+
status: Optional[str] = None,
|
|
136
|
+
) -> str:
|
|
137
|
+
"""Get products from the store.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
max_results: Maximum number of products to return (default 50, max 250).
|
|
141
|
+
status: Filter by status - 'ACTIVE', 'ARCHIVED', or 'DRAFT' (optional).
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
JSON string containing list of products with id, title, status, variants, and pricing.
|
|
145
|
+
"""
|
|
146
|
+
log_debug(f"Fetching products: max_results={max_results}, status={status}")
|
|
147
|
+
|
|
148
|
+
query_filter = ""
|
|
149
|
+
if status:
|
|
150
|
+
query_filter = f', query: "status:{status}"'
|
|
151
|
+
|
|
152
|
+
query = f"""
|
|
153
|
+
query {{
|
|
154
|
+
products(first: {min(max_results, 250)}{query_filter}) {{
|
|
155
|
+
edges {{
|
|
156
|
+
node {{
|
|
157
|
+
id
|
|
158
|
+
title
|
|
159
|
+
status
|
|
160
|
+
totalInventory
|
|
161
|
+
createdAt
|
|
162
|
+
updatedAt
|
|
163
|
+
priceRangeV2 {{
|
|
164
|
+
minVariantPrice {{
|
|
165
|
+
amount
|
|
166
|
+
currencyCode
|
|
167
|
+
}}
|
|
168
|
+
maxVariantPrice {{
|
|
169
|
+
amount
|
|
170
|
+
currencyCode
|
|
171
|
+
}}
|
|
172
|
+
}}
|
|
173
|
+
variants(first: 10) {{
|
|
174
|
+
edges {{
|
|
175
|
+
node {{
|
|
176
|
+
id
|
|
177
|
+
title
|
|
178
|
+
sku
|
|
179
|
+
price
|
|
180
|
+
inventoryQuantity
|
|
181
|
+
}}
|
|
182
|
+
}}
|
|
183
|
+
}}
|
|
184
|
+
}}
|
|
185
|
+
}}
|
|
186
|
+
}}
|
|
187
|
+
}}
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
result = self._make_graphql_request(query)
|
|
191
|
+
|
|
192
|
+
if "error" in result:
|
|
193
|
+
return json.dumps(result, indent=2)
|
|
194
|
+
|
|
195
|
+
products = []
|
|
196
|
+
for edge in result.get("products", {}).get("edges", []):
|
|
197
|
+
node = edge["node"]
|
|
198
|
+
products.append(
|
|
199
|
+
{
|
|
200
|
+
"id": node["id"],
|
|
201
|
+
"title": node["title"],
|
|
202
|
+
"status": node["status"],
|
|
203
|
+
"total_inventory": node.get("totalInventory"),
|
|
204
|
+
"created_at": node.get("createdAt"),
|
|
205
|
+
"price_range": {
|
|
206
|
+
"min": node.get("priceRangeV2", {}).get("minVariantPrice", {}).get("amount"),
|
|
207
|
+
"max": node.get("priceRangeV2", {}).get("maxVariantPrice", {}).get("amount"),
|
|
208
|
+
"currency": node.get("priceRangeV2", {}).get("minVariantPrice", {}).get("currencyCode"),
|
|
209
|
+
},
|
|
210
|
+
"variants": [
|
|
211
|
+
{
|
|
212
|
+
"id": v["node"]["id"],
|
|
213
|
+
"title": v["node"]["title"],
|
|
214
|
+
"sku": v["node"].get("sku"),
|
|
215
|
+
"price": v["node"]["price"],
|
|
216
|
+
"inventory": v["node"].get("inventoryQuantity"),
|
|
217
|
+
}
|
|
218
|
+
for v in node.get("variants", {}).get("edges", [])
|
|
219
|
+
],
|
|
220
|
+
}
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
return json.dumps(products, indent=2)
|
|
224
|
+
|
|
225
|
+
def get_orders(
|
|
226
|
+
self,
|
|
227
|
+
max_results: int = 50,
|
|
228
|
+
status: Optional[str] = None,
|
|
229
|
+
created_after: Optional[str] = None,
|
|
230
|
+
created_before: Optional[str] = None,
|
|
231
|
+
) -> str:
|
|
232
|
+
"""Get recent orders from the store.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
max_results: Maximum number of orders to return (default 50, max 250).
|
|
236
|
+
status: Filter by financial status - 'paid', 'pending', 'refunded' (optional).
|
|
237
|
+
created_after: Only include orders created after this date (YYYY-MM-DD format, optional).
|
|
238
|
+
created_before: Only include orders created before this date (YYYY-MM-DD format, optional).
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
JSON string containing list of orders with id, total, customer, and line items.
|
|
242
|
+
"""
|
|
243
|
+
log_debug(
|
|
244
|
+
f"Fetching orders: max_results={max_results}, status={status}, created_after={created_after}, created_before={created_before}"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
query_parts = []
|
|
248
|
+
if created_after:
|
|
249
|
+
query_parts.append(f"created_at:>={created_after}")
|
|
250
|
+
if created_before:
|
|
251
|
+
query_parts.append(f"created_at:<={created_before}")
|
|
252
|
+
if status:
|
|
253
|
+
query_parts.append(f"financial_status:{status}")
|
|
254
|
+
|
|
255
|
+
query_filter = " AND ".join(query_parts) if query_parts else ""
|
|
256
|
+
query_param = f', query: "{query_filter}"' if query_filter else ""
|
|
257
|
+
|
|
258
|
+
query = f"""
|
|
259
|
+
query {{
|
|
260
|
+
orders(first: {min(max_results, 250)}{query_param}, sortKey: CREATED_AT, reverse: true) {{
|
|
261
|
+
edges {{
|
|
262
|
+
node {{
|
|
263
|
+
id
|
|
264
|
+
name
|
|
265
|
+
createdAt
|
|
266
|
+
displayFinancialStatus
|
|
267
|
+
displayFulfillmentStatus
|
|
268
|
+
totalPriceSet {{
|
|
269
|
+
shopMoney {{
|
|
270
|
+
amount
|
|
271
|
+
currencyCode
|
|
272
|
+
}}
|
|
273
|
+
}}
|
|
274
|
+
subtotalPriceSet {{
|
|
275
|
+
shopMoney {{
|
|
276
|
+
amount
|
|
277
|
+
}}
|
|
278
|
+
}}
|
|
279
|
+
customer {{
|
|
280
|
+
id
|
|
281
|
+
email
|
|
282
|
+
firstName
|
|
283
|
+
lastName
|
|
284
|
+
}}
|
|
285
|
+
lineItems(first: 50) {{
|
|
286
|
+
edges {{
|
|
287
|
+
node {{
|
|
288
|
+
id
|
|
289
|
+
title
|
|
290
|
+
quantity
|
|
291
|
+
variant {{
|
|
292
|
+
id
|
|
293
|
+
sku
|
|
294
|
+
}}
|
|
295
|
+
originalUnitPriceSet {{
|
|
296
|
+
shopMoney {{
|
|
297
|
+
amount
|
|
298
|
+
}}
|
|
299
|
+
}}
|
|
300
|
+
}}
|
|
301
|
+
}}
|
|
302
|
+
}}
|
|
303
|
+
}}
|
|
304
|
+
}}
|
|
305
|
+
}}
|
|
306
|
+
}}
|
|
307
|
+
"""
|
|
308
|
+
|
|
309
|
+
result = self._make_graphql_request(query)
|
|
310
|
+
|
|
311
|
+
if "error" in result:
|
|
312
|
+
return json.dumps(result, indent=2)
|
|
313
|
+
|
|
314
|
+
orders = []
|
|
315
|
+
for edge in result.get("orders", {}).get("edges", []):
|
|
316
|
+
node = edge["node"]
|
|
317
|
+
customer = node.get("customer") or {}
|
|
318
|
+
orders.append(
|
|
319
|
+
{
|
|
320
|
+
"id": node["id"],
|
|
321
|
+
"name": node["name"],
|
|
322
|
+
"created_at": node["createdAt"],
|
|
323
|
+
"financial_status": node.get("displayFinancialStatus"),
|
|
324
|
+
"fulfillment_status": node.get("displayFulfillmentStatus"),
|
|
325
|
+
"total": node.get("totalPriceSet", {}).get("shopMoney", {}).get("amount"),
|
|
326
|
+
"subtotal": node.get("subtotalPriceSet", {}).get("shopMoney", {}).get("amount"),
|
|
327
|
+
"currency": node.get("totalPriceSet", {}).get("shopMoney", {}).get("currencyCode"),
|
|
328
|
+
"customer": {
|
|
329
|
+
"id": customer.get("id"),
|
|
330
|
+
"email": customer.get("email"),
|
|
331
|
+
"name": f"{customer.get('firstName', '')} {customer.get('lastName', '')}".strip(),
|
|
332
|
+
}
|
|
333
|
+
if customer
|
|
334
|
+
else None,
|
|
335
|
+
"line_items": [
|
|
336
|
+
{
|
|
337
|
+
"id": item["node"]["id"],
|
|
338
|
+
"title": item["node"]["title"],
|
|
339
|
+
"quantity": item["node"]["quantity"],
|
|
340
|
+
"unit_price": item["node"]
|
|
341
|
+
.get("originalUnitPriceSet", {})
|
|
342
|
+
.get("shopMoney", {})
|
|
343
|
+
.get("amount"),
|
|
344
|
+
"sku": item["node"].get("variant", {}).get("sku") if item["node"].get("variant") else None,
|
|
345
|
+
}
|
|
346
|
+
for item in node.get("lineItems", {}).get("edges", [])
|
|
347
|
+
],
|
|
348
|
+
}
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
return json.dumps(orders, indent=2)
|
|
352
|
+
|
|
353
|
+
def get_top_selling_products(
|
|
354
|
+
self,
|
|
355
|
+
limit: int = 10,
|
|
356
|
+
created_after: Optional[str] = None,
|
|
357
|
+
created_before: Optional[str] = None,
|
|
358
|
+
) -> str:
|
|
359
|
+
"""Get the top selling products by quantity sold.
|
|
360
|
+
|
|
361
|
+
Analyzes order data to find which products sell the most.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
limit: Number of top products to return (default 10).
|
|
365
|
+
created_after: Only include orders created after this date (YYYY-MM-DD format, optional).
|
|
366
|
+
created_before: Only include orders created before this date (YYYY-MM-DD format, optional).
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
JSON string containing ranked list of top products with sales data.
|
|
370
|
+
"""
|
|
371
|
+
log_debug(
|
|
372
|
+
f"Calculating top selling products: limit={limit}, created_after={created_after}, created_before={created_before}"
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
query_parts = ["financial_status:paid"]
|
|
376
|
+
if created_after:
|
|
377
|
+
query_parts.append(f"created_at:>={created_after}")
|
|
378
|
+
if created_before:
|
|
379
|
+
query_parts.append(f"created_at:<={created_before}")
|
|
380
|
+
query_filter = " AND ".join(query_parts)
|
|
381
|
+
|
|
382
|
+
query = f"""
|
|
383
|
+
query {{
|
|
384
|
+
orders(first: 250, query: "{query_filter}", sortKey: CREATED_AT) {{
|
|
385
|
+
edges {{
|
|
386
|
+
node {{
|
|
387
|
+
lineItems(first: 100) {{
|
|
388
|
+
edges {{
|
|
389
|
+
node {{
|
|
390
|
+
title
|
|
391
|
+
quantity
|
|
392
|
+
variant {{
|
|
393
|
+
id
|
|
394
|
+
product {{
|
|
395
|
+
id
|
|
396
|
+
title
|
|
397
|
+
}}
|
|
398
|
+
}}
|
|
399
|
+
originalUnitPriceSet {{
|
|
400
|
+
shopMoney {{
|
|
401
|
+
amount
|
|
402
|
+
}}
|
|
403
|
+
}}
|
|
404
|
+
}}
|
|
405
|
+
}}
|
|
406
|
+
}}
|
|
407
|
+
}}
|
|
408
|
+
}}
|
|
409
|
+
}}
|
|
410
|
+
}}
|
|
411
|
+
"""
|
|
412
|
+
|
|
413
|
+
result = self._make_graphql_request(query)
|
|
414
|
+
|
|
415
|
+
if "error" in result:
|
|
416
|
+
return json.dumps(result, indent=2)
|
|
417
|
+
|
|
418
|
+
# Aggregate sales by product
|
|
419
|
+
product_sales: Dict[str, Dict[str, Any]] = {}
|
|
420
|
+
|
|
421
|
+
for order_edge in result.get("orders", {}).get("edges", []):
|
|
422
|
+
for item_edge in order_edge["node"].get("lineItems", {}).get("edges", []):
|
|
423
|
+
item = item_edge["node"]
|
|
424
|
+
variant = item.get("variant")
|
|
425
|
+
if not variant or not variant.get("product"):
|
|
426
|
+
continue
|
|
427
|
+
|
|
428
|
+
product_id = variant["product"]["id"]
|
|
429
|
+
product_title = variant["product"]["title"]
|
|
430
|
+
quantity = item["quantity"]
|
|
431
|
+
unit_price = float(item.get("originalUnitPriceSet", {}).get("shopMoney", {}).get("amount", 0))
|
|
432
|
+
|
|
433
|
+
if product_id not in product_sales:
|
|
434
|
+
product_sales[product_id] = {
|
|
435
|
+
"id": product_id,
|
|
436
|
+
"title": product_title,
|
|
437
|
+
"total_quantity": 0,
|
|
438
|
+
"total_revenue": 0.0,
|
|
439
|
+
"order_count": 0,
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
product_sales[product_id]["total_quantity"] += quantity
|
|
443
|
+
product_sales[product_id]["total_revenue"] += quantity * unit_price
|
|
444
|
+
product_sales[product_id]["order_count"] += 1
|
|
445
|
+
|
|
446
|
+
# Sort by quantity and limit
|
|
447
|
+
sorted_products = sorted(product_sales.values(), key=lambda x: x["total_quantity"], reverse=True)[:limit]
|
|
448
|
+
|
|
449
|
+
# Add ranking
|
|
450
|
+
for i, product in enumerate(sorted_products):
|
|
451
|
+
product["rank"] = i + 1
|
|
452
|
+
product["total_revenue"] = round(product["total_revenue"], 2)
|
|
453
|
+
|
|
454
|
+
return json.dumps(sorted_products, indent=2)
|
|
455
|
+
|
|
456
|
+
def get_products_bought_together(
|
|
457
|
+
self,
|
|
458
|
+
min_occurrences: int = 2,
|
|
459
|
+
limit: int = 20,
|
|
460
|
+
created_after: Optional[str] = None,
|
|
461
|
+
created_before: Optional[str] = None,
|
|
462
|
+
) -> str:
|
|
463
|
+
"""Find products that are frequently bought together.
|
|
464
|
+
|
|
465
|
+
Analyzes orders to find product pairs that appear together most often.
|
|
466
|
+
Useful for cross-selling and bundle recommendations.
|
|
467
|
+
|
|
468
|
+
Args:
|
|
469
|
+
min_occurrences: Minimum times a pair must appear together (default 2).
|
|
470
|
+
limit: Number of product pairs to return (default 20).
|
|
471
|
+
created_after: Only include orders created after this date (YYYY-MM-DD format, optional).
|
|
472
|
+
created_before: Only include orders created before this date (YYYY-MM-DD format, optional).
|
|
473
|
+
|
|
474
|
+
Returns:
|
|
475
|
+
JSON string containing ranked list of product pairs with co-occurrence count.
|
|
476
|
+
"""
|
|
477
|
+
log_debug(f"Finding products bought together: created_after={created_after}, created_before={created_before}")
|
|
478
|
+
|
|
479
|
+
query_parts = ["financial_status:paid"]
|
|
480
|
+
if created_after:
|
|
481
|
+
query_parts.append(f"created_at:>={created_after}")
|
|
482
|
+
if created_before:
|
|
483
|
+
query_parts.append(f"created_at:<={created_before}")
|
|
484
|
+
query_filter = " AND ".join(query_parts)
|
|
485
|
+
|
|
486
|
+
query = f"""
|
|
487
|
+
query {{
|
|
488
|
+
orders(first: 250, query: "{query_filter}") {{
|
|
489
|
+
edges {{
|
|
490
|
+
node {{
|
|
491
|
+
lineItems(first: 100) {{
|
|
492
|
+
edges {{
|
|
493
|
+
node {{
|
|
494
|
+
variant {{
|
|
495
|
+
product {{
|
|
496
|
+
id
|
|
497
|
+
title
|
|
498
|
+
}}
|
|
499
|
+
}}
|
|
500
|
+
}}
|
|
501
|
+
}}
|
|
502
|
+
}}
|
|
503
|
+
}}
|
|
504
|
+
}}
|
|
505
|
+
}}
|
|
506
|
+
}}
|
|
507
|
+
"""
|
|
508
|
+
|
|
509
|
+
result = self._make_graphql_request(query)
|
|
510
|
+
|
|
511
|
+
if "error" in result:
|
|
512
|
+
return json.dumps(result, indent=2)
|
|
513
|
+
|
|
514
|
+
# Count co-occurrences
|
|
515
|
+
pair_counter: Counter = Counter()
|
|
516
|
+
product_info: Dict[str, str] = {}
|
|
517
|
+
|
|
518
|
+
for order_edge in result.get("orders", {}).get("edges", []):
|
|
519
|
+
products_in_order = []
|
|
520
|
+
for item_edge in order_edge["node"].get("lineItems", {}).get("edges", []):
|
|
521
|
+
variant = item_edge["node"].get("variant")
|
|
522
|
+
if variant and variant.get("product"):
|
|
523
|
+
product_id = variant["product"]["id"]
|
|
524
|
+
product_title = variant["product"]["title"]
|
|
525
|
+
products_in_order.append(product_id)
|
|
526
|
+
product_info[product_id] = product_title
|
|
527
|
+
|
|
528
|
+
# Get unique products in this order and count pairs
|
|
529
|
+
unique_products = list(set(products_in_order))
|
|
530
|
+
if len(unique_products) >= 2:
|
|
531
|
+
for pair in combinations(sorted(unique_products), 2):
|
|
532
|
+
pair_counter[pair] += 1
|
|
533
|
+
|
|
534
|
+
# Filter by minimum occurrences and sort
|
|
535
|
+
frequent_pairs = [
|
|
536
|
+
{
|
|
537
|
+
"product_1": {
|
|
538
|
+
"id": pair[0],
|
|
539
|
+
"title": product_info.get(pair[0], "Unknown"),
|
|
540
|
+
},
|
|
541
|
+
"product_2": {
|
|
542
|
+
"id": pair[1],
|
|
543
|
+
"title": product_info.get(pair[1], "Unknown"),
|
|
544
|
+
},
|
|
545
|
+
"times_bought_together": count,
|
|
546
|
+
}
|
|
547
|
+
for pair, count in pair_counter.most_common(limit)
|
|
548
|
+
if count >= min_occurrences
|
|
549
|
+
]
|
|
550
|
+
|
|
551
|
+
return json.dumps(frequent_pairs, indent=2)
|
|
552
|
+
|
|
553
|
+
def get_sales_by_date_range(
|
|
554
|
+
self,
|
|
555
|
+
start_date: str,
|
|
556
|
+
end_date: str,
|
|
557
|
+
) -> str:
|
|
558
|
+
"""Get sales summary for a specific date range.
|
|
559
|
+
|
|
560
|
+
Args:
|
|
561
|
+
start_date: Start date in YYYY-MM-DD format.
|
|
562
|
+
end_date: End date in YYYY-MM-DD format.
|
|
563
|
+
|
|
564
|
+
Returns:
|
|
565
|
+
JSON string containing total revenue, order count, and daily breakdown.
|
|
566
|
+
"""
|
|
567
|
+
log_debug(f"Fetching sales for date range: {start_date} to {end_date}")
|
|
568
|
+
|
|
569
|
+
query = f"""
|
|
570
|
+
query {{
|
|
571
|
+
orders(first: 250, query: "created_at:>={start_date} AND created_at:<={end_date} AND financial_status:paid", sortKey: CREATED_AT) {{
|
|
572
|
+
edges {{
|
|
573
|
+
node {{
|
|
574
|
+
createdAt
|
|
575
|
+
totalPriceSet {{
|
|
576
|
+
shopMoney {{
|
|
577
|
+
amount
|
|
578
|
+
currencyCode
|
|
579
|
+
}}
|
|
580
|
+
}}
|
|
581
|
+
lineItems(first: 100) {{
|
|
582
|
+
edges {{
|
|
583
|
+
node {{
|
|
584
|
+
quantity
|
|
585
|
+
}}
|
|
586
|
+
}}
|
|
587
|
+
}}
|
|
588
|
+
}}
|
|
589
|
+
}}
|
|
590
|
+
}}
|
|
591
|
+
}}
|
|
592
|
+
"""
|
|
593
|
+
|
|
594
|
+
result = self._make_graphql_request(query)
|
|
595
|
+
|
|
596
|
+
if "error" in result:
|
|
597
|
+
return json.dumps(result, indent=2)
|
|
598
|
+
|
|
599
|
+
# Aggregate data
|
|
600
|
+
total_revenue = 0.0
|
|
601
|
+
total_orders = 0
|
|
602
|
+
total_items = 0
|
|
603
|
+
daily_breakdown: Dict[str, Dict[str, Any]] = {}
|
|
604
|
+
currency = None
|
|
605
|
+
|
|
606
|
+
for order_edge in result.get("orders", {}).get("edges", []):
|
|
607
|
+
order = order_edge["node"]
|
|
608
|
+
amount = float(order.get("totalPriceSet", {}).get("shopMoney", {}).get("amount", 0))
|
|
609
|
+
currency = order.get("totalPriceSet", {}).get("shopMoney", {}).get("currencyCode")
|
|
610
|
+
date = order["createdAt"][:10] # Extract date part
|
|
611
|
+
|
|
612
|
+
items_in_order = sum(item["node"]["quantity"] for item in order.get("lineItems", {}).get("edges", []))
|
|
613
|
+
|
|
614
|
+
total_revenue += amount
|
|
615
|
+
total_orders += 1
|
|
616
|
+
total_items += items_in_order
|
|
617
|
+
|
|
618
|
+
if date not in daily_breakdown:
|
|
619
|
+
daily_breakdown[date] = {"date": date, "revenue": 0.0, "orders": 0, "items": 0}
|
|
620
|
+
daily_breakdown[date]["revenue"] += amount
|
|
621
|
+
daily_breakdown[date]["orders"] += 1
|
|
622
|
+
daily_breakdown[date]["items"] += items_in_order
|
|
623
|
+
|
|
624
|
+
# Sort daily breakdown by date
|
|
625
|
+
sorted_daily = sorted(daily_breakdown.values(), key=lambda x: x["date"])
|
|
626
|
+
for day in sorted_daily:
|
|
627
|
+
day["revenue"] = round(day["revenue"], 2)
|
|
628
|
+
|
|
629
|
+
summary = {
|
|
630
|
+
"period": {"start": start_date, "end": end_date},
|
|
631
|
+
"total_revenue": round(total_revenue, 2),
|
|
632
|
+
"total_orders": total_orders,
|
|
633
|
+
"total_items_sold": total_items,
|
|
634
|
+
"average_order_value": round(total_revenue / total_orders, 2) if total_orders > 0 else 0,
|
|
635
|
+
"currency": currency,
|
|
636
|
+
"daily_breakdown": sorted_daily,
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
return json.dumps(summary, indent=2)
|
|
640
|
+
|
|
641
|
+
def get_order_analytics(
|
|
642
|
+
self,
|
|
643
|
+
created_after: Optional[str] = None,
|
|
644
|
+
created_before: Optional[str] = None,
|
|
645
|
+
) -> str:
|
|
646
|
+
"""Get comprehensive order analytics for a time period.
|
|
647
|
+
|
|
648
|
+
Provides metrics like total orders, revenue, average order value,
|
|
649
|
+
fulfillment rates, and more.
|
|
650
|
+
|
|
651
|
+
Args:
|
|
652
|
+
created_after: Only include orders created after this date (YYYY-MM-DD format, optional).
|
|
653
|
+
created_before: Only include orders created before this date (YYYY-MM-DD format, optional).
|
|
654
|
+
|
|
655
|
+
Returns:
|
|
656
|
+
JSON string containing various order metrics and statistics.
|
|
657
|
+
"""
|
|
658
|
+
log_debug(f"Generating order analytics: created_after={created_after}, created_before={created_before}")
|
|
659
|
+
|
|
660
|
+
query_parts = []
|
|
661
|
+
if created_after:
|
|
662
|
+
query_parts.append(f"created_at:>={created_after}")
|
|
663
|
+
if created_before:
|
|
664
|
+
query_parts.append(f"created_at:<={created_before}")
|
|
665
|
+
query_filter = " AND ".join(query_parts) if query_parts else ""
|
|
666
|
+
query_param = f', query: "{query_filter}"' if query_filter else ""
|
|
667
|
+
|
|
668
|
+
query = f"""
|
|
669
|
+
query {{
|
|
670
|
+
orders(first: 250{query_param}, sortKey: CREATED_AT) {{
|
|
671
|
+
edges {{
|
|
672
|
+
node {{
|
|
673
|
+
displayFinancialStatus
|
|
674
|
+
displayFulfillmentStatus
|
|
675
|
+
totalPriceSet {{
|
|
676
|
+
shopMoney {{
|
|
677
|
+
amount
|
|
678
|
+
currencyCode
|
|
679
|
+
}}
|
|
680
|
+
}}
|
|
681
|
+
subtotalPriceSet {{
|
|
682
|
+
shopMoney {{
|
|
683
|
+
amount
|
|
684
|
+
}}
|
|
685
|
+
}}
|
|
686
|
+
totalShippingPriceSet {{
|
|
687
|
+
shopMoney {{
|
|
688
|
+
amount
|
|
689
|
+
}}
|
|
690
|
+
}}
|
|
691
|
+
totalTaxSet {{
|
|
692
|
+
shopMoney {{
|
|
693
|
+
amount
|
|
694
|
+
}}
|
|
695
|
+
}}
|
|
696
|
+
lineItems(first: 100) {{
|
|
697
|
+
edges {{
|
|
698
|
+
node {{
|
|
699
|
+
quantity
|
|
700
|
+
}}
|
|
701
|
+
}}
|
|
702
|
+
}}
|
|
703
|
+
}}
|
|
704
|
+
}}
|
|
705
|
+
}}
|
|
706
|
+
}}
|
|
707
|
+
"""
|
|
708
|
+
|
|
709
|
+
result = self._make_graphql_request(query)
|
|
710
|
+
|
|
711
|
+
if "error" in result:
|
|
712
|
+
return json.dumps(result, indent=2)
|
|
713
|
+
|
|
714
|
+
orders = result.get("orders", {}).get("edges", [])
|
|
715
|
+
|
|
716
|
+
if not orders:
|
|
717
|
+
return json.dumps({"message": "No orders found in the specified period"}, indent=2)
|
|
718
|
+
|
|
719
|
+
# Calculate metrics
|
|
720
|
+
total_orders = len(orders)
|
|
721
|
+
total_revenue = 0.0
|
|
722
|
+
total_subtotal = 0.0
|
|
723
|
+
total_shipping = 0.0
|
|
724
|
+
total_tax = 0.0
|
|
725
|
+
total_items = 0
|
|
726
|
+
currency = None
|
|
727
|
+
|
|
728
|
+
financial_status_counts: Counter = Counter()
|
|
729
|
+
fulfillment_status_counts: Counter = Counter()
|
|
730
|
+
order_values: List[float] = []
|
|
731
|
+
|
|
732
|
+
for order_edge in orders:
|
|
733
|
+
order = order_edge["node"]
|
|
734
|
+
|
|
735
|
+
amount = float(order.get("totalPriceSet", {}).get("shopMoney", {}).get("amount", 0))
|
|
736
|
+
currency = order.get("totalPriceSet", {}).get("shopMoney", {}).get("currencyCode")
|
|
737
|
+
|
|
738
|
+
total_revenue += amount
|
|
739
|
+
total_subtotal += float(order.get("subtotalPriceSet", {}).get("shopMoney", {}).get("amount", 0))
|
|
740
|
+
total_shipping += float(order.get("totalShippingPriceSet", {}).get("shopMoney", {}).get("amount", 0))
|
|
741
|
+
total_tax += float(order.get("totalTaxSet", {}).get("shopMoney", {}).get("amount", 0))
|
|
742
|
+
|
|
743
|
+
order_values.append(amount)
|
|
744
|
+
|
|
745
|
+
items = sum(item["node"]["quantity"] for item in order.get("lineItems", {}).get("edges", []))
|
|
746
|
+
total_items += items
|
|
747
|
+
|
|
748
|
+
financial_status_counts[order.get("displayFinancialStatus", "UNKNOWN")] += 1
|
|
749
|
+
fulfillment_status_counts[order.get("displayFulfillmentStatus", "UNKNOWN")] += 1
|
|
750
|
+
|
|
751
|
+
avg_order_value = total_revenue / total_orders if total_orders > 0 else 0
|
|
752
|
+
avg_items_per_order = total_items / total_orders if total_orders > 0 else 0
|
|
753
|
+
|
|
754
|
+
analytics = {
|
|
755
|
+
"period": {
|
|
756
|
+
"created_after": created_after,
|
|
757
|
+
"created_before": created_before,
|
|
758
|
+
},
|
|
759
|
+
"total_orders": total_orders,
|
|
760
|
+
"total_revenue": round(total_revenue, 2),
|
|
761
|
+
"total_subtotal": round(total_subtotal, 2),
|
|
762
|
+
"total_shipping": round(total_shipping, 2),
|
|
763
|
+
"total_tax": round(total_tax, 2),
|
|
764
|
+
"currency": currency,
|
|
765
|
+
"average_order_value": round(avg_order_value, 2),
|
|
766
|
+
"total_items_sold": total_items,
|
|
767
|
+
"average_items_per_order": round(avg_items_per_order, 2),
|
|
768
|
+
"min_order_value": round(min(order_values), 2) if order_values else 0,
|
|
769
|
+
"max_order_value": round(max(order_values), 2) if order_values else 0,
|
|
770
|
+
"financial_status_breakdown": dict(financial_status_counts),
|
|
771
|
+
"fulfillment_status_breakdown": dict(fulfillment_status_counts),
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
return json.dumps(analytics, indent=2)
|
|
775
|
+
|
|
776
|
+
def get_product_sales_breakdown(
|
|
777
|
+
self,
|
|
778
|
+
product_id: str,
|
|
779
|
+
created_after: Optional[str] = None,
|
|
780
|
+
created_before: Optional[str] = None,
|
|
781
|
+
) -> str:
|
|
782
|
+
"""Get detailed sales breakdown for a specific product.
|
|
783
|
+
|
|
784
|
+
Args:
|
|
785
|
+
product_id: The Shopify product ID (gid://shopify/Product/xxx or just the number).
|
|
786
|
+
created_after: Only include orders created after this date (YYYY-MM-DD format, optional).
|
|
787
|
+
created_before: Only include orders created before this date (YYYY-MM-DD format, optional).
|
|
788
|
+
|
|
789
|
+
Returns:
|
|
790
|
+
JSON string containing product sales data including quantity, revenue, and variant breakdown.
|
|
791
|
+
"""
|
|
792
|
+
log_debug(f"Fetching sales breakdown for product: {product_id}")
|
|
793
|
+
|
|
794
|
+
# Normalize product ID
|
|
795
|
+
if not product_id.startswith("gid://"):
|
|
796
|
+
product_id = f"gid://shopify/Product/{product_id}"
|
|
797
|
+
|
|
798
|
+
query_parts = ["financial_status:paid"]
|
|
799
|
+
if created_after:
|
|
800
|
+
query_parts.append(f"created_at:>={created_after}")
|
|
801
|
+
if created_before:
|
|
802
|
+
query_parts.append(f"created_at:<={created_before}")
|
|
803
|
+
query_filter = " AND ".join(query_parts)
|
|
804
|
+
|
|
805
|
+
query = f"""
|
|
806
|
+
query {{
|
|
807
|
+
orders(first: 250, query: "{query_filter}") {{
|
|
808
|
+
edges {{
|
|
809
|
+
node {{
|
|
810
|
+
createdAt
|
|
811
|
+
lineItems(first: 100) {{
|
|
812
|
+
edges {{
|
|
813
|
+
node {{
|
|
814
|
+
title
|
|
815
|
+
quantity
|
|
816
|
+
variant {{
|
|
817
|
+
id
|
|
818
|
+
title
|
|
819
|
+
sku
|
|
820
|
+
product {{
|
|
821
|
+
id
|
|
822
|
+
title
|
|
823
|
+
}}
|
|
824
|
+
}}
|
|
825
|
+
originalUnitPriceSet {{
|
|
826
|
+
shopMoney {{
|
|
827
|
+
amount
|
|
828
|
+
currencyCode
|
|
829
|
+
}}
|
|
830
|
+
}}
|
|
831
|
+
}}
|
|
832
|
+
}}
|
|
833
|
+
}}
|
|
834
|
+
}}
|
|
835
|
+
}}
|
|
836
|
+
}}
|
|
837
|
+
}}
|
|
838
|
+
"""
|
|
839
|
+
|
|
840
|
+
result = self._make_graphql_request(query)
|
|
841
|
+
|
|
842
|
+
if "error" in result:
|
|
843
|
+
return json.dumps(result, indent=2)
|
|
844
|
+
|
|
845
|
+
# Filter for specific product and aggregate
|
|
846
|
+
product_title = None
|
|
847
|
+
total_quantity = 0
|
|
848
|
+
total_revenue = 0.0
|
|
849
|
+
order_count = 0
|
|
850
|
+
currency = None
|
|
851
|
+
variant_breakdown: Dict[str, Dict[str, Any]] = {}
|
|
852
|
+
daily_sales: Dict[str, Dict[str, Any]] = {}
|
|
853
|
+
|
|
854
|
+
for order_edge in result.get("orders", {}).get("edges", []):
|
|
855
|
+
order = order_edge["node"]
|
|
856
|
+
order_date = order["createdAt"][:10]
|
|
857
|
+
found_in_order = False
|
|
858
|
+
|
|
859
|
+
for item_edge in order.get("lineItems", {}).get("edges", []):
|
|
860
|
+
item = item_edge["node"]
|
|
861
|
+
variant = item.get("variant")
|
|
862
|
+
if not variant or not variant.get("product"):
|
|
863
|
+
continue
|
|
864
|
+
|
|
865
|
+
if variant["product"]["id"] == product_id:
|
|
866
|
+
product_title = variant["product"]["title"]
|
|
867
|
+
quantity = item["quantity"]
|
|
868
|
+
unit_price = float(item.get("originalUnitPriceSet", {}).get("shopMoney", {}).get("amount", 0))
|
|
869
|
+
currency = item.get("originalUnitPriceSet", {}).get("shopMoney", {}).get("currencyCode")
|
|
870
|
+
variant_id = variant["id"]
|
|
871
|
+
variant_title = variant.get("title", "Default")
|
|
872
|
+
|
|
873
|
+
total_quantity += quantity
|
|
874
|
+
total_revenue += quantity * unit_price
|
|
875
|
+
found_in_order = True
|
|
876
|
+
|
|
877
|
+
# Variant breakdown
|
|
878
|
+
if variant_id not in variant_breakdown:
|
|
879
|
+
variant_breakdown[variant_id] = {
|
|
880
|
+
"variant_id": variant_id,
|
|
881
|
+
"variant_title": variant_title,
|
|
882
|
+
"sku": variant.get("sku"),
|
|
883
|
+
"quantity": 0,
|
|
884
|
+
"revenue": 0.0,
|
|
885
|
+
}
|
|
886
|
+
variant_breakdown[variant_id]["quantity"] += quantity
|
|
887
|
+
variant_breakdown[variant_id]["revenue"] += quantity * unit_price
|
|
888
|
+
|
|
889
|
+
# Daily breakdown
|
|
890
|
+
if order_date not in daily_sales:
|
|
891
|
+
daily_sales[order_date] = {"date": order_date, "quantity": 0, "revenue": 0.0}
|
|
892
|
+
daily_sales[order_date]["quantity"] += quantity
|
|
893
|
+
daily_sales[order_date]["revenue"] += quantity * unit_price
|
|
894
|
+
|
|
895
|
+
if found_in_order:
|
|
896
|
+
order_count += 1
|
|
897
|
+
|
|
898
|
+
if product_title is None:
|
|
899
|
+
return json.dumps({"error": "Product not found in any orders during this period"}, indent=2)
|
|
900
|
+
|
|
901
|
+
# Format variants and daily data
|
|
902
|
+
for v in variant_breakdown.values():
|
|
903
|
+
v["revenue"] = round(v["revenue"], 2)
|
|
904
|
+
|
|
905
|
+
sorted_daily = sorted(daily_sales.values(), key=lambda x: x["date"])
|
|
906
|
+
for d in sorted_daily:
|
|
907
|
+
d["revenue"] = round(d["revenue"], 2)
|
|
908
|
+
|
|
909
|
+
breakdown = {
|
|
910
|
+
"product_id": product_id,
|
|
911
|
+
"product_title": product_title,
|
|
912
|
+
"period": {
|
|
913
|
+
"created_after": created_after,
|
|
914
|
+
"created_before": created_before,
|
|
915
|
+
},
|
|
916
|
+
"total_quantity_sold": total_quantity,
|
|
917
|
+
"total_revenue": round(total_revenue, 2),
|
|
918
|
+
"order_count": order_count,
|
|
919
|
+
"currency": currency,
|
|
920
|
+
"average_units_per_order": round(total_quantity / order_count, 2) if order_count > 0 else 0,
|
|
921
|
+
"variant_breakdown": list(variant_breakdown.values()),
|
|
922
|
+
"daily_sales": sorted_daily,
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
return json.dumps(breakdown, indent=2)
|
|
926
|
+
|
|
927
|
+
def get_customer_order_history(
|
|
928
|
+
self,
|
|
929
|
+
customer_email: str,
|
|
930
|
+
max_orders: int = 50,
|
|
931
|
+
) -> str:
|
|
932
|
+
"""Get order history for a specific customer.
|
|
933
|
+
|
|
934
|
+
Args:
|
|
935
|
+
customer_email: The customer's email address.
|
|
936
|
+
max_orders: Maximum number of orders to return (default 50).
|
|
937
|
+
|
|
938
|
+
Returns:
|
|
939
|
+
JSON string containing customer info and their order history.
|
|
940
|
+
"""
|
|
941
|
+
log_debug(f"Fetching order history for customer: {customer_email}")
|
|
942
|
+
|
|
943
|
+
query = f"""
|
|
944
|
+
query {{
|
|
945
|
+
orders(first: {min(max_orders, 250)}, query: "email:{customer_email}", sortKey: CREATED_AT, reverse: true) {{
|
|
946
|
+
edges {{
|
|
947
|
+
node {{
|
|
948
|
+
id
|
|
949
|
+
name
|
|
950
|
+
createdAt
|
|
951
|
+
displayFinancialStatus
|
|
952
|
+
displayFulfillmentStatus
|
|
953
|
+
totalPriceSet {{
|
|
954
|
+
shopMoney {{
|
|
955
|
+
amount
|
|
956
|
+
currencyCode
|
|
957
|
+
}}
|
|
958
|
+
}}
|
|
959
|
+
customer {{
|
|
960
|
+
id
|
|
961
|
+
firstName
|
|
962
|
+
lastName
|
|
963
|
+
numberOfOrders
|
|
964
|
+
amountSpent {{
|
|
965
|
+
amount
|
|
966
|
+
currencyCode
|
|
967
|
+
}}
|
|
968
|
+
}}
|
|
969
|
+
lineItems(first: 20) {{
|
|
970
|
+
edges {{
|
|
971
|
+
node {{
|
|
972
|
+
title
|
|
973
|
+
quantity
|
|
974
|
+
}}
|
|
975
|
+
}}
|
|
976
|
+
}}
|
|
977
|
+
}}
|
|
978
|
+
}}
|
|
979
|
+
}}
|
|
980
|
+
}}
|
|
981
|
+
"""
|
|
982
|
+
|
|
983
|
+
result = self._make_graphql_request(query)
|
|
984
|
+
|
|
985
|
+
if "error" in result:
|
|
986
|
+
return json.dumps(result, indent=2)
|
|
987
|
+
|
|
988
|
+
orders = result.get("orders", {}).get("edges", [])
|
|
989
|
+
|
|
990
|
+
if not orders:
|
|
991
|
+
return json.dumps({"message": f"No orders found for {customer_email}"}, indent=2)
|
|
992
|
+
|
|
993
|
+
# Get customer info from first order
|
|
994
|
+
first_customer = orders[0]["node"].get("customer", {}) if orders else {}
|
|
995
|
+
|
|
996
|
+
customer_info = {
|
|
997
|
+
"email": customer_email,
|
|
998
|
+
"id": first_customer.get("id"),
|
|
999
|
+
"name": f"{first_customer.get('firstName', '')} {first_customer.get('lastName', '')}".strip(),
|
|
1000
|
+
"total_orders": first_customer.get("numberOfOrders"),
|
|
1001
|
+
"total_spent": first_customer.get("amountSpent", {}).get("amount"),
|
|
1002
|
+
"currency": first_customer.get("amountSpent", {}).get("currencyCode"),
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
order_list = []
|
|
1006
|
+
for order_edge in orders:
|
|
1007
|
+
order = order_edge["node"]
|
|
1008
|
+
order_list.append(
|
|
1009
|
+
{
|
|
1010
|
+
"id": order["id"],
|
|
1011
|
+
"name": order["name"],
|
|
1012
|
+
"created_at": order["createdAt"],
|
|
1013
|
+
"financial_status": order.get("displayFinancialStatus"),
|
|
1014
|
+
"fulfillment_status": order.get("displayFulfillmentStatus"),
|
|
1015
|
+
"total": order.get("totalPriceSet", {}).get("shopMoney", {}).get("amount"),
|
|
1016
|
+
"items": [
|
|
1017
|
+
{"title": item["node"]["title"], "quantity": item["node"]["quantity"]}
|
|
1018
|
+
for item in order.get("lineItems", {}).get("edges", [])
|
|
1019
|
+
],
|
|
1020
|
+
}
|
|
1021
|
+
)
|
|
1022
|
+
|
|
1023
|
+
response = {
|
|
1024
|
+
"customer": customer_info,
|
|
1025
|
+
"orders": order_list,
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
return json.dumps(response, indent=2)
|
|
1029
|
+
|
|
1030
|
+
def get_inventory_levels(
|
|
1031
|
+
self,
|
|
1032
|
+
max_results: int = 100,
|
|
1033
|
+
) -> str:
|
|
1034
|
+
"""Get current inventory levels for all products.
|
|
1035
|
+
|
|
1036
|
+
Args:
|
|
1037
|
+
max_results: Maximum number of products to return (default 100).
|
|
1038
|
+
|
|
1039
|
+
Returns:
|
|
1040
|
+
JSON string containing products with their inventory quantities.
|
|
1041
|
+
"""
|
|
1042
|
+
log_debug(f"Fetching inventory levels: max_results={max_results}")
|
|
1043
|
+
|
|
1044
|
+
query = f"""
|
|
1045
|
+
query {{
|
|
1046
|
+
products(first: {min(max_results, 250)}, query: "status:ACTIVE") {{
|
|
1047
|
+
edges {{
|
|
1048
|
+
node {{
|
|
1049
|
+
id
|
|
1050
|
+
title
|
|
1051
|
+
totalInventory
|
|
1052
|
+
tracksInventory
|
|
1053
|
+
variants(first: 50) {{
|
|
1054
|
+
edges {{
|
|
1055
|
+
node {{
|
|
1056
|
+
id
|
|
1057
|
+
title
|
|
1058
|
+
sku
|
|
1059
|
+
inventoryQuantity
|
|
1060
|
+
inventoryPolicy
|
|
1061
|
+
}}
|
|
1062
|
+
}}
|
|
1063
|
+
}}
|
|
1064
|
+
}}
|
|
1065
|
+
}}
|
|
1066
|
+
}}
|
|
1067
|
+
}}
|
|
1068
|
+
"""
|
|
1069
|
+
|
|
1070
|
+
result = self._make_graphql_request(query)
|
|
1071
|
+
|
|
1072
|
+
if "error" in result:
|
|
1073
|
+
return json.dumps(result, indent=2)
|
|
1074
|
+
|
|
1075
|
+
products = []
|
|
1076
|
+
for edge in result.get("products", {}).get("edges", []):
|
|
1077
|
+
node = edge["node"]
|
|
1078
|
+
products.append(
|
|
1079
|
+
{
|
|
1080
|
+
"id": node["id"],
|
|
1081
|
+
"title": node["title"],
|
|
1082
|
+
"total_inventory": node.get("totalInventory"),
|
|
1083
|
+
"tracks_inventory": node.get("tracksInventory"),
|
|
1084
|
+
"variants": [
|
|
1085
|
+
{
|
|
1086
|
+
"id": v["node"]["id"],
|
|
1087
|
+
"title": v["node"]["title"],
|
|
1088
|
+
"sku": v["node"].get("sku"),
|
|
1089
|
+
"inventory_quantity": v["node"].get("inventoryQuantity"),
|
|
1090
|
+
"inventory_policy": v["node"].get("inventoryPolicy"),
|
|
1091
|
+
}
|
|
1092
|
+
for v in node.get("variants", {}).get("edges", [])
|
|
1093
|
+
],
|
|
1094
|
+
}
|
|
1095
|
+
)
|
|
1096
|
+
|
|
1097
|
+
return json.dumps(products, indent=2)
|
|
1098
|
+
|
|
1099
|
+
def get_low_stock_products(
|
|
1100
|
+
self,
|
|
1101
|
+
threshold: int = 10,
|
|
1102
|
+
max_results: int = 50,
|
|
1103
|
+
) -> str:
|
|
1104
|
+
"""Get products that are running low on stock.
|
|
1105
|
+
|
|
1106
|
+
Args:
|
|
1107
|
+
threshold: Inventory level below which a product is considered low stock (default 10).
|
|
1108
|
+
max_results: Maximum number of products to return (default 50).
|
|
1109
|
+
|
|
1110
|
+
Returns:
|
|
1111
|
+
JSON string containing low stock products sorted by inventory level.
|
|
1112
|
+
"""
|
|
1113
|
+
log_debug(f"Finding low stock products: threshold={threshold}")
|
|
1114
|
+
|
|
1115
|
+
query = """
|
|
1116
|
+
query {{
|
|
1117
|
+
products(first: 250, query: "status:ACTIVE") {{
|
|
1118
|
+
edges {{
|
|
1119
|
+
node {{
|
|
1120
|
+
id
|
|
1121
|
+
title
|
|
1122
|
+
totalInventory
|
|
1123
|
+
variants(first: 50) {{
|
|
1124
|
+
edges {{
|
|
1125
|
+
node {{
|
|
1126
|
+
id
|
|
1127
|
+
title
|
|
1128
|
+
sku
|
|
1129
|
+
inventoryQuantity
|
|
1130
|
+
}}
|
|
1131
|
+
}}
|
|
1132
|
+
}}
|
|
1133
|
+
}}
|
|
1134
|
+
}}
|
|
1135
|
+
}}
|
|
1136
|
+
}}
|
|
1137
|
+
"""
|
|
1138
|
+
|
|
1139
|
+
result = self._make_graphql_request(query)
|
|
1140
|
+
|
|
1141
|
+
if "error" in result:
|
|
1142
|
+
return json.dumps(result, indent=2)
|
|
1143
|
+
|
|
1144
|
+
low_stock = []
|
|
1145
|
+
for edge in result.get("products", {}).get("edges", []):
|
|
1146
|
+
node = edge["node"]
|
|
1147
|
+
total_inv = node.get("totalInventory", 0)
|
|
1148
|
+
|
|
1149
|
+
if total_inv is not None and total_inv <= threshold:
|
|
1150
|
+
low_stock_variants = [
|
|
1151
|
+
{
|
|
1152
|
+
"id": v["node"]["id"],
|
|
1153
|
+
"title": v["node"]["title"],
|
|
1154
|
+
"sku": v["node"].get("sku"),
|
|
1155
|
+
"inventory_quantity": v["node"].get("inventoryQuantity"),
|
|
1156
|
+
}
|
|
1157
|
+
for v in node.get("variants", {}).get("edges", [])
|
|
1158
|
+
if v["node"].get("inventoryQuantity") is not None and v["node"]["inventoryQuantity"] <= threshold
|
|
1159
|
+
]
|
|
1160
|
+
|
|
1161
|
+
if low_stock_variants:
|
|
1162
|
+
low_stock.append(
|
|
1163
|
+
{
|
|
1164
|
+
"id": node["id"],
|
|
1165
|
+
"title": node["title"],
|
|
1166
|
+
"total_inventory": total_inv,
|
|
1167
|
+
"low_stock_variants": low_stock_variants,
|
|
1168
|
+
}
|
|
1169
|
+
)
|
|
1170
|
+
|
|
1171
|
+
# Sort by total inventory (lowest first)
|
|
1172
|
+
low_stock.sort(key=lambda x: x["total_inventory"])
|
|
1173
|
+
|
|
1174
|
+
return json.dumps(low_stock[:max_results], indent=2)
|
|
1175
|
+
|
|
1176
|
+
def get_sales_trends(
|
|
1177
|
+
self,
|
|
1178
|
+
created_after: Optional[str] = None,
|
|
1179
|
+
created_before: Optional[str] = None,
|
|
1180
|
+
compare_previous_period: bool = True,
|
|
1181
|
+
) -> str:
|
|
1182
|
+
"""Get sales trends comparing current period to previous period.
|
|
1183
|
+
|
|
1184
|
+
Args:
|
|
1185
|
+
created_after: Start date for analysis period (YYYY-MM-DD format, optional).
|
|
1186
|
+
created_before: End date for analysis period (YYYY-MM-DD format, optional).
|
|
1187
|
+
compare_previous_period: Whether to compare with previous period of same length (default True).
|
|
1188
|
+
|
|
1189
|
+
Returns:
|
|
1190
|
+
JSON string containing current period metrics and comparison with previous period.
|
|
1191
|
+
"""
|
|
1192
|
+
log_debug(f"Calculating sales trends: created_after={created_after}, created_before={created_before}")
|
|
1193
|
+
|
|
1194
|
+
# Use provided dates or default to last 30 days
|
|
1195
|
+
now = datetime.now()
|
|
1196
|
+
if created_before:
|
|
1197
|
+
current_end_dt = datetime.strptime(created_before, "%Y-%m-%d")
|
|
1198
|
+
else:
|
|
1199
|
+
current_end_dt = now
|
|
1200
|
+
current_end = current_end_dt.strftime("%Y-%m-%d")
|
|
1201
|
+
|
|
1202
|
+
if created_after:
|
|
1203
|
+
current_start_dt = datetime.strptime(created_after, "%Y-%m-%d")
|
|
1204
|
+
else:
|
|
1205
|
+
current_start_dt = current_end_dt - timedelta(days=30)
|
|
1206
|
+
current_start = current_start_dt.strftime("%Y-%m-%d")
|
|
1207
|
+
|
|
1208
|
+
# Calculate previous period of same length
|
|
1209
|
+
period_days = (current_end_dt - current_start_dt).days
|
|
1210
|
+
previous_end_dt = current_start_dt - timedelta(days=1)
|
|
1211
|
+
previous_start_dt = previous_end_dt - timedelta(days=period_days)
|
|
1212
|
+
previous_start = previous_start_dt.strftime("%Y-%m-%d")
|
|
1213
|
+
previous_end = previous_end_dt.strftime("%Y-%m-%d")
|
|
1214
|
+
|
|
1215
|
+
def fetch_period_data(start: str, end: str) -> Dict[str, Any]:
|
|
1216
|
+
query = f"""
|
|
1217
|
+
query {{
|
|
1218
|
+
orders(first: 250, query: "created_at:>={start} AND created_at:<{end} AND financial_status:paid") {{
|
|
1219
|
+
edges {{
|
|
1220
|
+
node {{
|
|
1221
|
+
totalPriceSet {{
|
|
1222
|
+
shopMoney {{
|
|
1223
|
+
amount
|
|
1224
|
+
currencyCode
|
|
1225
|
+
}}
|
|
1226
|
+
}}
|
|
1227
|
+
lineItems(first: 100) {{
|
|
1228
|
+
edges {{
|
|
1229
|
+
node {{
|
|
1230
|
+
quantity
|
|
1231
|
+
}}
|
|
1232
|
+
}}
|
|
1233
|
+
}}
|
|
1234
|
+
}}
|
|
1235
|
+
}}
|
|
1236
|
+
}}
|
|
1237
|
+
}}
|
|
1238
|
+
"""
|
|
1239
|
+
result = self._make_graphql_request(query)
|
|
1240
|
+
if "error" in result:
|
|
1241
|
+
return {"error": result["error"]}
|
|
1242
|
+
|
|
1243
|
+
orders = result.get("orders", {}).get("edges", [])
|
|
1244
|
+
total_revenue = sum(
|
|
1245
|
+
float(o["node"].get("totalPriceSet", {}).get("shopMoney", {}).get("amount", 0)) for o in orders
|
|
1246
|
+
)
|
|
1247
|
+
total_items = sum(
|
|
1248
|
+
sum(item["node"]["quantity"] for item in o["node"].get("lineItems", {}).get("edges", []))
|
|
1249
|
+
for o in orders
|
|
1250
|
+
)
|
|
1251
|
+
currency = (
|
|
1252
|
+
orders[0]["node"].get("totalPriceSet", {}).get("shopMoney", {}).get("currencyCode") if orders else None
|
|
1253
|
+
)
|
|
1254
|
+
|
|
1255
|
+
return {
|
|
1256
|
+
"total_orders": len(orders),
|
|
1257
|
+
"total_revenue": round(total_revenue, 2),
|
|
1258
|
+
"total_items_sold": total_items,
|
|
1259
|
+
"average_order_value": round(total_revenue / len(orders), 2) if orders else 0,
|
|
1260
|
+
"currency": currency,
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
current_data = fetch_period_data(current_start, current_end)
|
|
1264
|
+
if "error" in current_data:
|
|
1265
|
+
return json.dumps(current_data, indent=2)
|
|
1266
|
+
|
|
1267
|
+
result = {
|
|
1268
|
+
"current_period": {
|
|
1269
|
+
"start": current_start,
|
|
1270
|
+
"end": current_end,
|
|
1271
|
+
**current_data,
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
if compare_previous_period:
|
|
1276
|
+
previous_data = fetch_period_data(previous_start, previous_end)
|
|
1277
|
+
if "error" not in previous_data:
|
|
1278
|
+
result["previous_period"] = {
|
|
1279
|
+
"start": previous_start,
|
|
1280
|
+
"end": previous_end,
|
|
1281
|
+
**previous_data,
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
# Calculate changes
|
|
1285
|
+
if previous_data["total_revenue"] > 0:
|
|
1286
|
+
revenue_change = (
|
|
1287
|
+
(current_data["total_revenue"] - previous_data["total_revenue"])
|
|
1288
|
+
/ previous_data["total_revenue"]
|
|
1289
|
+
) * 100
|
|
1290
|
+
else:
|
|
1291
|
+
revenue_change = 100 if current_data["total_revenue"] > 0 else 0
|
|
1292
|
+
|
|
1293
|
+
if previous_data["total_orders"] > 0:
|
|
1294
|
+
orders_change = (
|
|
1295
|
+
(current_data["total_orders"] - previous_data["total_orders"]) / previous_data["total_orders"]
|
|
1296
|
+
) * 100
|
|
1297
|
+
else:
|
|
1298
|
+
orders_change = 100 if current_data["total_orders"] > 0 else 0
|
|
1299
|
+
|
|
1300
|
+
result["comparison"] = {
|
|
1301
|
+
"revenue_change_percent": round(revenue_change, 2),
|
|
1302
|
+
"orders_change_percent": round(orders_change, 2),
|
|
1303
|
+
"revenue_trend": "up" if revenue_change > 0 else ("down" if revenue_change < 0 else "flat"),
|
|
1304
|
+
"orders_trend": "up" if orders_change > 0 else ("down" if orders_change < 0 else "flat"),
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
return json.dumps(result, indent=2)
|
|
1308
|
+
|
|
1309
|
+
def get_average_order_value(
|
|
1310
|
+
self,
|
|
1311
|
+
group_by: str = "day",
|
|
1312
|
+
created_after: Optional[str] = None,
|
|
1313
|
+
created_before: Optional[str] = None,
|
|
1314
|
+
) -> str:
|
|
1315
|
+
"""Get average order value over time.
|
|
1316
|
+
|
|
1317
|
+
Args:
|
|
1318
|
+
group_by: How to group data - 'day', 'week', or 'month' (default 'day').
|
|
1319
|
+
created_after: Only include orders created after this date (YYYY-MM-DD format, optional).
|
|
1320
|
+
created_before: Only include orders created before this date (YYYY-MM-DD format, optional).
|
|
1321
|
+
|
|
1322
|
+
Returns:
|
|
1323
|
+
JSON string containing average order value trends.
|
|
1324
|
+
"""
|
|
1325
|
+
log_debug(
|
|
1326
|
+
f"Calculating AOV: group_by={group_by}, created_after={created_after}, created_before={created_before}"
|
|
1327
|
+
)
|
|
1328
|
+
|
|
1329
|
+
query_parts = ["financial_status:paid"]
|
|
1330
|
+
if created_after:
|
|
1331
|
+
query_parts.append(f"created_at:>={created_after}")
|
|
1332
|
+
if created_before:
|
|
1333
|
+
query_parts.append(f"created_at:<={created_before}")
|
|
1334
|
+
query_filter = " AND ".join(query_parts)
|
|
1335
|
+
|
|
1336
|
+
query = f"""
|
|
1337
|
+
query {{
|
|
1338
|
+
orders(first: 250, query: "{query_filter}", sortKey: CREATED_AT) {{
|
|
1339
|
+
edges {{
|
|
1340
|
+
node {{
|
|
1341
|
+
createdAt
|
|
1342
|
+
totalPriceSet {{
|
|
1343
|
+
shopMoney {{
|
|
1344
|
+
amount
|
|
1345
|
+
currencyCode
|
|
1346
|
+
}}
|
|
1347
|
+
}}
|
|
1348
|
+
}}
|
|
1349
|
+
}}
|
|
1350
|
+
}}
|
|
1351
|
+
}}
|
|
1352
|
+
"""
|
|
1353
|
+
|
|
1354
|
+
result = self._make_graphql_request(query)
|
|
1355
|
+
|
|
1356
|
+
if "error" in result:
|
|
1357
|
+
return json.dumps(result, indent=2)
|
|
1358
|
+
|
|
1359
|
+
orders = result.get("orders", {}).get("edges", [])
|
|
1360
|
+
|
|
1361
|
+
if not orders:
|
|
1362
|
+
return json.dumps({"message": "No orders found in the specified period"}, indent=2)
|
|
1363
|
+
|
|
1364
|
+
# Group orders
|
|
1365
|
+
grouped: Dict[str, List[float]] = {}
|
|
1366
|
+
currency = None
|
|
1367
|
+
|
|
1368
|
+
for order_edge in orders:
|
|
1369
|
+
order = order_edge["node"]
|
|
1370
|
+
created_at = order["createdAt"][:10]
|
|
1371
|
+
amount = float(order.get("totalPriceSet", {}).get("shopMoney", {}).get("amount", 0))
|
|
1372
|
+
currency = order.get("totalPriceSet", {}).get("shopMoney", {}).get("currencyCode")
|
|
1373
|
+
|
|
1374
|
+
if group_by == "week":
|
|
1375
|
+
# Get ISO week
|
|
1376
|
+
date_obj = datetime.strptime(created_at, "%Y-%m-%d")
|
|
1377
|
+
key = f"{date_obj.isocalendar()[0]}-W{date_obj.isocalendar()[1]:02d}"
|
|
1378
|
+
elif group_by == "month":
|
|
1379
|
+
key = created_at[:7] # YYYY-MM
|
|
1380
|
+
else:
|
|
1381
|
+
key = created_at
|
|
1382
|
+
|
|
1383
|
+
if key not in grouped:
|
|
1384
|
+
grouped[key] = []
|
|
1385
|
+
grouped[key].append(amount)
|
|
1386
|
+
|
|
1387
|
+
# Calculate averages
|
|
1388
|
+
aov_data = [
|
|
1389
|
+
{
|
|
1390
|
+
"period": key,
|
|
1391
|
+
"order_count": len(values),
|
|
1392
|
+
"total_revenue": round(sum(values), 2),
|
|
1393
|
+
"average_order_value": round(sum(values) / len(values), 2),
|
|
1394
|
+
}
|
|
1395
|
+
for key, values in sorted(grouped.items())
|
|
1396
|
+
]
|
|
1397
|
+
|
|
1398
|
+
overall_avg = sum(o["total_revenue"] for o in aov_data) / sum(o["order_count"] for o in aov_data) # type: ignore
|
|
1399
|
+
|
|
1400
|
+
response = {
|
|
1401
|
+
"period": {
|
|
1402
|
+
"created_after": created_after,
|
|
1403
|
+
"created_before": created_before,
|
|
1404
|
+
},
|
|
1405
|
+
"group_by": group_by,
|
|
1406
|
+
"overall_average_order_value": round(overall_avg, 2),
|
|
1407
|
+
"currency": currency,
|
|
1408
|
+
"breakdown": aov_data,
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
return json.dumps(response, indent=2)
|
|
1412
|
+
|
|
1413
|
+
def get_repeat_customers(
|
|
1414
|
+
self,
|
|
1415
|
+
min_orders: int = 2,
|
|
1416
|
+
limit: int = 50,
|
|
1417
|
+
created_after: Optional[str] = None,
|
|
1418
|
+
created_before: Optional[str] = None,
|
|
1419
|
+
) -> str:
|
|
1420
|
+
"""Find customers who have made multiple purchases.
|
|
1421
|
+
|
|
1422
|
+
Args:
|
|
1423
|
+
min_orders: Minimum number of orders to qualify as repeat customer (default 2).
|
|
1424
|
+
limit: Maximum number of customers to return (default 50).
|
|
1425
|
+
created_after: Only include orders created after this date (YYYY-MM-DD format, optional).
|
|
1426
|
+
created_before: Only include orders created before this date (YYYY-MM-DD format, optional).
|
|
1427
|
+
|
|
1428
|
+
Returns:
|
|
1429
|
+
JSON string containing repeat customers with their order counts and total spend.
|
|
1430
|
+
"""
|
|
1431
|
+
log_debug(
|
|
1432
|
+
f"Finding repeat customers: min_orders={min_orders}, created_after={created_after}, created_before={created_before}"
|
|
1433
|
+
)
|
|
1434
|
+
|
|
1435
|
+
query_parts = ["financial_status:paid"]
|
|
1436
|
+
if created_after:
|
|
1437
|
+
query_parts.append(f"created_at:>={created_after}")
|
|
1438
|
+
if created_before:
|
|
1439
|
+
query_parts.append(f"created_at:<={created_before}")
|
|
1440
|
+
query_filter = " AND ".join(query_parts)
|
|
1441
|
+
|
|
1442
|
+
query = f"""
|
|
1443
|
+
query {{
|
|
1444
|
+
orders(first: 250, query: "{query_filter}") {{
|
|
1445
|
+
edges {{
|
|
1446
|
+
node {{
|
|
1447
|
+
customer {{
|
|
1448
|
+
id
|
|
1449
|
+
email
|
|
1450
|
+
firstName
|
|
1451
|
+
lastName
|
|
1452
|
+
numberOfOrders
|
|
1453
|
+
amountSpent {{
|
|
1454
|
+
amount
|
|
1455
|
+
currencyCode
|
|
1456
|
+
}}
|
|
1457
|
+
}}
|
|
1458
|
+
totalPriceSet {{
|
|
1459
|
+
shopMoney {{
|
|
1460
|
+
amount
|
|
1461
|
+
}}
|
|
1462
|
+
}}
|
|
1463
|
+
}}
|
|
1464
|
+
}}
|
|
1465
|
+
}}
|
|
1466
|
+
}}
|
|
1467
|
+
"""
|
|
1468
|
+
|
|
1469
|
+
result = self._make_graphql_request(query)
|
|
1470
|
+
|
|
1471
|
+
if "error" in result:
|
|
1472
|
+
return json.dumps(result, indent=2)
|
|
1473
|
+
|
|
1474
|
+
# Aggregate by customer
|
|
1475
|
+
customer_data: Dict[str, Dict[str, Any]] = {}
|
|
1476
|
+
|
|
1477
|
+
for order_edge in result.get("orders", {}).get("edges", []):
|
|
1478
|
+
customer = order_edge["node"].get("customer")
|
|
1479
|
+
if not customer or not customer.get("id"):
|
|
1480
|
+
continue
|
|
1481
|
+
|
|
1482
|
+
customer_id = customer["id"]
|
|
1483
|
+
order_amount = float(order_edge["node"].get("totalPriceSet", {}).get("shopMoney", {}).get("amount", 0))
|
|
1484
|
+
|
|
1485
|
+
if customer_id not in customer_data:
|
|
1486
|
+
customer_data[customer_id] = {
|
|
1487
|
+
"id": customer_id,
|
|
1488
|
+
"email": customer.get("email"),
|
|
1489
|
+
"name": f"{customer.get('firstName', '')} {customer.get('lastName', '')}".strip(),
|
|
1490
|
+
"total_orders_all_time": customer.get("numberOfOrders"),
|
|
1491
|
+
"total_spent_all_time": customer.get("amountSpent", {}).get("amount"),
|
|
1492
|
+
"currency": customer.get("amountSpent", {}).get("currencyCode"),
|
|
1493
|
+
"orders_in_period": 0,
|
|
1494
|
+
"spent_in_period": 0.0,
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
customer_data[customer_id]["orders_in_period"] += 1
|
|
1498
|
+
customer_data[customer_id]["spent_in_period"] += order_amount
|
|
1499
|
+
|
|
1500
|
+
# Filter repeat customers and sort
|
|
1501
|
+
repeat_customers = [
|
|
1502
|
+
{**c, "spent_in_period": round(c["spent_in_period"], 2)}
|
|
1503
|
+
for c in customer_data.values()
|
|
1504
|
+
if c["orders_in_period"] >= min_orders
|
|
1505
|
+
]
|
|
1506
|
+
|
|
1507
|
+
repeat_customers.sort(key=lambda x: x["orders_in_period"], reverse=True)
|
|
1508
|
+
|
|
1509
|
+
response = {
|
|
1510
|
+
"period": {
|
|
1511
|
+
"created_after": created_after,
|
|
1512
|
+
"created_before": created_before,
|
|
1513
|
+
},
|
|
1514
|
+
"min_orders_threshold": min_orders,
|
|
1515
|
+
"repeat_customer_count": len(repeat_customers),
|
|
1516
|
+
"customers": repeat_customers[:limit],
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
return json.dumps(response, indent=2)
|