qualmcp 0.1.0
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.
- package/package.json +22 -0
- package/src/cli.js +10 -0
- package/src/db.js +27 -0
- package/src/index.js +58 -0
- package/src/launch-env.js +14 -0
- package/src/sql.js +58 -0
- package/src/tools.js +379 -0
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "qualmcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"qualmcp": "src/cli.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"src"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"start": "node src/index.js",
|
|
13
|
+
"start:env": "node src/launch-env.js"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
17
|
+
"dotenv": "^16.4.5",
|
|
18
|
+
"pg": "^8.11.3",
|
|
19
|
+
"zod": "^3.23.8",
|
|
20
|
+
"zod-to-json-schema": "^3.24.1"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/cli.js
ADDED
package/src/db.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import pg from 'pg';
|
|
2
|
+
|
|
3
|
+
const { Pool } = pg;
|
|
4
|
+
|
|
5
|
+
function getDatabaseUrl() {
|
|
6
|
+
const url = process.env.DATABASE_URL;
|
|
7
|
+
if (!url) {
|
|
8
|
+
throw new Error('DATABASE_URL is required');
|
|
9
|
+
}
|
|
10
|
+
return url;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function buildSslConfig() {
|
|
14
|
+
const mode = String(process.env.PGSSLMODE || '').toLowerCase();
|
|
15
|
+
if (!mode || mode === 'disable' || mode === 'false' || mode === '0') return false;
|
|
16
|
+
if (mode === 'require' || mode === 'true' || mode === '1') return { rejectUnauthorized: false };
|
|
17
|
+
return { rejectUnauthorized: false };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const pool = new Pool({
|
|
21
|
+
connectionString: getDatabaseUrl(),
|
|
22
|
+
ssl: buildSslConfig()
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export async function query(sql, params = []) {
|
|
26
|
+
return await pool.query(sql, params);
|
|
27
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
|
|
3
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
4
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
|
+
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
6
|
+
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
7
|
+
|
|
8
|
+
import { tools } from './tools.js';
|
|
9
|
+
|
|
10
|
+
const server = new Server(
|
|
11
|
+
{
|
|
12
|
+
name: 'qualmcp',
|
|
13
|
+
version: '0.1.0'
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
capabilities: {
|
|
17
|
+
tools: {}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
23
|
+
return {
|
|
24
|
+
tools: Object.values(tools).map((t) => ({
|
|
25
|
+
name: t.name,
|
|
26
|
+
description: t.description,
|
|
27
|
+
inputSchema: zodToJsonSchema(t.schema, { name: t.name })
|
|
28
|
+
}))
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
33
|
+
const name = req?.params?.name;
|
|
34
|
+
const args = req?.params?.arguments || {};
|
|
35
|
+
|
|
36
|
+
const entry = Object.values(tools).find((t) => t.name === name);
|
|
37
|
+
if (!entry) {
|
|
38
|
+
return {
|
|
39
|
+
content: [{ type: 'text', text: JSON.stringify({ success: false, error: `Unknown tool: ${name}` }) }]
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const parsed = entry.schema.parse(args);
|
|
45
|
+
const result = await entry.handler(parsed);
|
|
46
|
+
return {
|
|
47
|
+
content: [{ type: 'text', text: JSON.stringify({ success: true, result }, null, 2) }]
|
|
48
|
+
};
|
|
49
|
+
} catch (e) {
|
|
50
|
+
const msg = e?.message || String(e);
|
|
51
|
+
return {
|
|
52
|
+
content: [{ type: 'text', text: JSON.stringify({ success: false, error: msg }, null, 2) }]
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const transport = new StdioServerTransport();
|
|
58
|
+
await server.connect(transport);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
|
|
4
|
+
function main() {
|
|
5
|
+
const dbUrl = String(process.env.DATABASE_URL || '').trim();
|
|
6
|
+
if (!dbUrl) {
|
|
7
|
+
throw new Error('DATABASE_URL is required');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const entry = path.resolve('src/index.js');
|
|
11
|
+
import(entry);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
main();
|
package/src/sql.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export function buildOrderScopeWhere({ mode, statusIds, statusKeys, excludeSpam } = {}, startIndex = 1) {
|
|
2
|
+
const clauses = [];
|
|
3
|
+
const params = [];
|
|
4
|
+
|
|
5
|
+
const safeMode = mode || 'all';
|
|
6
|
+
let nextIndex = Number.isFinite(Number(startIndex)) ? Number(startIndex) : 1;
|
|
7
|
+
|
|
8
|
+
const pushParam = (value) => {
|
|
9
|
+
params.push(value);
|
|
10
|
+
const idx = nextIndex;
|
|
11
|
+
nextIndex += 1;
|
|
12
|
+
return idx;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
if (safeMode === 'orderedFlag') {
|
|
16
|
+
clauses.push('os.is_ordered = TRUE');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (safeMode === 'statusIds') {
|
|
20
|
+
const ids = Array.isArray(statusIds)
|
|
21
|
+
? Array.from(new Set(statusIds.map((x) => Number(x)).filter((n) => Number.isFinite(n))))
|
|
22
|
+
: [];
|
|
23
|
+
if (ids.length > 0) {
|
|
24
|
+
const idx = pushParam(ids);
|
|
25
|
+
clauses.push(`o.status_id = ANY($${idx})`);
|
|
26
|
+
} else {
|
|
27
|
+
clauses.push('1=0');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (safeMode === 'statusKeys') {
|
|
32
|
+
const keys = Array.isArray(statusKeys)
|
|
33
|
+
? Array.from(new Set(statusKeys.map((x) => String(x || '').trim()).filter(Boolean)))
|
|
34
|
+
: [];
|
|
35
|
+
if (keys.length > 0) {
|
|
36
|
+
const idx = pushParam(keys);
|
|
37
|
+
clauses.push(`os.key = ANY($${idx})`);
|
|
38
|
+
} else {
|
|
39
|
+
clauses.push('1=0');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (excludeSpam === true) {
|
|
44
|
+
clauses.push('COALESCE(os.is_spam, FALSE) = FALSE');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
whereSql: clauses.length > 0 ? clauses.join(' AND ') : '1=1',
|
|
49
|
+
params,
|
|
50
|
+
nextIndex
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function normalizeDateField(dateField) {
|
|
55
|
+
const allowed = new Set(['created_at', 'ordered_at', 'updated_at']);
|
|
56
|
+
const f = String(dateField || '').trim();
|
|
57
|
+
return allowed.has(f) ? f : 'created_at';
|
|
58
|
+
}
|
package/src/tools.js
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { query } from './db.js';
|
|
3
|
+
import { buildOrderScopeWhere, normalizeDateField } from './sql.js';
|
|
4
|
+
|
|
5
|
+
const OrderScopeSchema = z.object({
|
|
6
|
+
mode: z.enum(['all', 'statusIds', 'statusKeys', 'orderedFlag']).default('all'),
|
|
7
|
+
statusIds: z.array(z.number()).optional(),
|
|
8
|
+
statusKeys: z.array(z.string()).optional(),
|
|
9
|
+
excludeSpam: z.boolean().optional()
|
|
10
|
+
}).optional();
|
|
11
|
+
|
|
12
|
+
const DateFieldSchema = z.enum(['created_at', 'ordered_at', 'updated_at']).optional();
|
|
13
|
+
|
|
14
|
+
function parseSkuTag(productSku) {
|
|
15
|
+
const raw = String(productSku || '').trim();
|
|
16
|
+
if (!raw) return { sku: null, tag: null, compound: null };
|
|
17
|
+
if (raw.includes('/')) {
|
|
18
|
+
const [sku, tag] = raw.split('/', 2);
|
|
19
|
+
const base = String(sku || '').trim();
|
|
20
|
+
const t = String(tag || '').trim();
|
|
21
|
+
return { sku: base || null, tag: t || null, compound: `${base}/${t}` };
|
|
22
|
+
}
|
|
23
|
+
return { sku: raw, tag: null, compound: raw };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function safeLimit(v, max = 200) {
|
|
27
|
+
const n = Number(v);
|
|
28
|
+
if (!Number.isFinite(n)) return 50;
|
|
29
|
+
return Math.min(max, Math.max(1, Math.floor(n)));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const tools = {
|
|
33
|
+
analytics_salesRank: {
|
|
34
|
+
name: 'analytics.salesRank',
|
|
35
|
+
description: 'Ranking of products by sales quantity/revenue across orders with flexible filters and grouping. Always returns product names when possible.',
|
|
36
|
+
schema: z.object({
|
|
37
|
+
dateFrom: z.string().datetime().optional(),
|
|
38
|
+
dateTo: z.string().datetime().optional(),
|
|
39
|
+
dateField: DateFieldSchema,
|
|
40
|
+
managerId: z.number().optional(),
|
|
41
|
+
orderScope: OrderScopeSchema,
|
|
42
|
+
groupBy: z.enum(['sku', 'sku_tag']).default('sku_tag'),
|
|
43
|
+
metric: z.enum(['qty', 'revenueApprox', 'ordersCount']).default('qty'),
|
|
44
|
+
limit: z.number().optional(),
|
|
45
|
+
offset: z.number().optional()
|
|
46
|
+
}),
|
|
47
|
+
handler: async (input) => {
|
|
48
|
+
const {
|
|
49
|
+
dateFrom,
|
|
50
|
+
dateTo,
|
|
51
|
+
dateField,
|
|
52
|
+
managerId,
|
|
53
|
+
orderScope,
|
|
54
|
+
groupBy,
|
|
55
|
+
metric,
|
|
56
|
+
limit,
|
|
57
|
+
offset
|
|
58
|
+
} = input;
|
|
59
|
+
|
|
60
|
+
const field = normalizeDateField(dateField);
|
|
61
|
+
const scope = buildOrderScopeWhere(orderScope);
|
|
62
|
+
|
|
63
|
+
const params = [...scope.params];
|
|
64
|
+
const clauses = [scope.whereSql];
|
|
65
|
+
|
|
66
|
+
if (dateFrom) {
|
|
67
|
+
params.push(dateFrom);
|
|
68
|
+
clauses.push(`o.${field} >= $${params.length}`);
|
|
69
|
+
}
|
|
70
|
+
if (dateTo) {
|
|
71
|
+
params.push(dateTo);
|
|
72
|
+
clauses.push(`o.${field} <= $${params.length}`);
|
|
73
|
+
}
|
|
74
|
+
if (managerId != null) {
|
|
75
|
+
params.push(Number(managerId));
|
|
76
|
+
clauses.push(`o.manager_id = $${params.length}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const where = clauses.length > 0 ? clauses.join(' AND ') : '1=1';
|
|
80
|
+
const lim = safeLimit(limit, 500);
|
|
81
|
+
const off = Math.max(0, Math.floor(Number(offset || 0)));
|
|
82
|
+
|
|
83
|
+
const groupExpr = groupBy === 'sku'
|
|
84
|
+
? `base_sku`
|
|
85
|
+
: `compound_sku`;
|
|
86
|
+
|
|
87
|
+
const orderExpr = metric === 'ordersCount'
|
|
88
|
+
? 'orders_count'
|
|
89
|
+
: metric === 'revenueApprox'
|
|
90
|
+
? 'revenue_approx'
|
|
91
|
+
: 'qty';
|
|
92
|
+
|
|
93
|
+
const sql = `
|
|
94
|
+
WITH expanded AS (
|
|
95
|
+
SELECT
|
|
96
|
+
o.id AS order_id,
|
|
97
|
+
o.manager_id,
|
|
98
|
+
o.${field} AS order_date,
|
|
99
|
+
x.sku AS raw_sku,
|
|
100
|
+
COALESCE(x.quantity, 1) AS qty,
|
|
101
|
+
CASE WHEN POSITION('/' IN x.sku) > 0 THEN SUBSTRING(x.sku FROM 1 FOR POSITION('/' IN x.sku) - 1) ELSE x.sku END AS base_sku,
|
|
102
|
+
CASE WHEN POSITION('/' IN x.sku) > 0 THEN SUBSTRING(x.sku FROM POSITION('/' IN x.sku) + 1) ELSE NULL END AS tag,
|
|
103
|
+
x.sku AS compound_sku
|
|
104
|
+
FROM orders o
|
|
105
|
+
LEFT JOIN order_statuses os ON os.id = o.status_id
|
|
106
|
+
JOIN LATERAL jsonb_to_recordset(o.products) AS x(sku text, quantity int) ON TRUE
|
|
107
|
+
WHERE ${where}
|
|
108
|
+
AND x.sku IS NOT NULL AND x.sku != ''
|
|
109
|
+
),
|
|
110
|
+
agg AS (
|
|
111
|
+
SELECT
|
|
112
|
+
${groupExpr} AS group_key,
|
|
113
|
+
base_sku,
|
|
114
|
+
tag,
|
|
115
|
+
SUM(qty)::bigint AS qty,
|
|
116
|
+
COUNT(DISTINCT order_id)::bigint AS orders_count
|
|
117
|
+
FROM expanded
|
|
118
|
+
GROUP BY ${groupExpr}, base_sku, tag
|
|
119
|
+
),
|
|
120
|
+
product_pick AS (
|
|
121
|
+
SELECT
|
|
122
|
+
a.group_key,
|
|
123
|
+
a.base_sku,
|
|
124
|
+
a.tag,
|
|
125
|
+
a.qty,
|
|
126
|
+
a.orders_count,
|
|
127
|
+
p.id AS product_id,
|
|
128
|
+
p.name AS product_name,
|
|
129
|
+
p.price,
|
|
130
|
+
p.discount,
|
|
131
|
+
ROUND((COALESCE(p.price::numeric, 0) * (1 - COALESCE(p.discount::numeric, 0) / 100))::numeric, 2) AS final_price
|
|
132
|
+
FROM agg a
|
|
133
|
+
LEFT JOIN LATERAL (
|
|
134
|
+
SELECT id, name, price, discount
|
|
135
|
+
FROM products
|
|
136
|
+
WHERE sku = a.base_sku
|
|
137
|
+
AND ( (a.tag IS NOT NULL AND tag = a.tag) OR (a.tag IS NULL) )
|
|
138
|
+
ORDER BY
|
|
139
|
+
CASE WHEN a.tag IS NOT NULL AND tag = a.tag THEN 0 ELSE 1 END,
|
|
140
|
+
created_at ASC
|
|
141
|
+
LIMIT 1
|
|
142
|
+
) p ON TRUE
|
|
143
|
+
)
|
|
144
|
+
SELECT
|
|
145
|
+
group_key,
|
|
146
|
+
base_sku AS sku,
|
|
147
|
+
tag,
|
|
148
|
+
product_name,
|
|
149
|
+
qty,
|
|
150
|
+
orders_count,
|
|
151
|
+
(qty * COALESCE(final_price, 0))::numeric AS revenue_approx
|
|
152
|
+
FROM product_pick
|
|
153
|
+
ORDER BY ${orderExpr} DESC NULLS LAST, qty DESC
|
|
154
|
+
LIMIT ${lim} OFFSET ${off}
|
|
155
|
+
`;
|
|
156
|
+
|
|
157
|
+
const res = await query(sql, params);
|
|
158
|
+
return {
|
|
159
|
+
rows: res.rows || [],
|
|
160
|
+
meta: {
|
|
161
|
+
metric,
|
|
162
|
+
groupBy,
|
|
163
|
+
dateField: field,
|
|
164
|
+
limit: lim,
|
|
165
|
+
offset: off
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
analytics_stockHealth: {
|
|
172
|
+
name: 'analytics.stockHealth',
|
|
173
|
+
description: 'Stock + sales velocity and days-cover metrics per product (optionally per store). Returns product names and signals; interpretation is left to the agent.',
|
|
174
|
+
schema: z.object({
|
|
175
|
+
storeId: z.number().optional(),
|
|
176
|
+
salesWindowDays: z.number().default(30),
|
|
177
|
+
dateTo: z.string().datetime().optional(),
|
|
178
|
+
dateField: DateFieldSchema,
|
|
179
|
+
orderScope: OrderScopeSchema,
|
|
180
|
+
includeZeroSales: z.boolean().default(true),
|
|
181
|
+
minSalesQty: z.number().default(0),
|
|
182
|
+
limit: z.number().optional(),
|
|
183
|
+
offset: z.number().optional()
|
|
184
|
+
}),
|
|
185
|
+
handler: async (input) => {
|
|
186
|
+
const {
|
|
187
|
+
storeId,
|
|
188
|
+
salesWindowDays,
|
|
189
|
+
dateTo,
|
|
190
|
+
dateField,
|
|
191
|
+
orderScope,
|
|
192
|
+
includeZeroSales,
|
|
193
|
+
minSalesQty,
|
|
194
|
+
limit,
|
|
195
|
+
offset
|
|
196
|
+
} = input;
|
|
197
|
+
|
|
198
|
+
const field = normalizeDateField(dateField);
|
|
199
|
+
const to = dateTo ? new Date(dateTo) : new Date();
|
|
200
|
+
const windowDays = Math.max(1, Math.min(3650, Math.floor(Number(salesWindowDays || 30))));
|
|
201
|
+
const from = new Date(to.getTime() - windowDays * 24 * 60 * 60 * 1000);
|
|
202
|
+
|
|
203
|
+
const hasStore = storeId != null;
|
|
204
|
+
const storeParams = hasStore ? [Number(storeId)] : [];
|
|
205
|
+
const scope = buildOrderScopeWhere(orderScope, hasStore ? 2 : 1);
|
|
206
|
+
|
|
207
|
+
const params = [...storeParams, ...scope.params];
|
|
208
|
+
const clauses = [scope.whereSql];
|
|
209
|
+
|
|
210
|
+
params.push(from.toISOString());
|
|
211
|
+
clauses.push(`o.${field} >= $${params.length}`);
|
|
212
|
+
params.push(to.toISOString());
|
|
213
|
+
clauses.push(`o.${field} <= $${params.length}`);
|
|
214
|
+
|
|
215
|
+
const where = clauses.length > 0 ? clauses.join(' AND ') : '1=1';
|
|
216
|
+
const lim = safeLimit(limit, 500);
|
|
217
|
+
const off = Math.max(0, Math.floor(Number(offset || 0)));
|
|
218
|
+
|
|
219
|
+
const sql = `
|
|
220
|
+
WITH stock AS (
|
|
221
|
+
SELECT
|
|
222
|
+
${storeId != null ? 'si.store_id' : 'NULL::bigint'} AS store_id,
|
|
223
|
+
si.product_sku AS compound_sku,
|
|
224
|
+
SUM(COALESCE(si.quantity, 0))::bigint AS stock_qty
|
|
225
|
+
FROM store_inventory si
|
|
226
|
+
${storeId != null ? 'WHERE si.store_id = $1' : ''}
|
|
227
|
+
GROUP BY ${storeId != null ? 'si.store_id, si.product_sku' : 'si.product_sku'}
|
|
228
|
+
),
|
|
229
|
+
sales_expanded AS (
|
|
230
|
+
SELECT
|
|
231
|
+
x.sku AS compound_sku,
|
|
232
|
+
COALESCE(x.quantity, 1) AS qty
|
|
233
|
+
FROM orders o
|
|
234
|
+
LEFT JOIN order_statuses os ON os.id = o.status_id
|
|
235
|
+
JOIN LATERAL jsonb_to_recordset(o.products) AS x(sku text, quantity int) ON TRUE
|
|
236
|
+
WHERE ${where}
|
|
237
|
+
AND x.sku IS NOT NULL AND x.sku != ''
|
|
238
|
+
),
|
|
239
|
+
sales AS (
|
|
240
|
+
SELECT
|
|
241
|
+
compound_sku,
|
|
242
|
+
SUM(qty)::bigint AS sales_qty_window
|
|
243
|
+
FROM sales_expanded
|
|
244
|
+
GROUP BY compound_sku
|
|
245
|
+
),
|
|
246
|
+
merged AS (
|
|
247
|
+
SELECT
|
|
248
|
+
s.store_id,
|
|
249
|
+
s.compound_sku,
|
|
250
|
+
s.stock_qty,
|
|
251
|
+
COALESCE(sa.sales_qty_window, 0)::bigint AS sales_qty_window
|
|
252
|
+
FROM stock s
|
|
253
|
+
LEFT JOIN sales sa ON sa.compound_sku = s.compound_sku
|
|
254
|
+
),
|
|
255
|
+
parsed AS (
|
|
256
|
+
SELECT
|
|
257
|
+
m.*,
|
|
258
|
+
CASE WHEN POSITION('/' IN m.compound_sku) > 0 THEN SUBSTRING(m.compound_sku FROM 1 FOR POSITION('/' IN m.compound_sku) - 1) ELSE m.compound_sku END AS base_sku,
|
|
259
|
+
CASE WHEN POSITION('/' IN m.compound_sku) > 0 THEN SUBSTRING(m.compound_sku FROM POSITION('/' IN m.compound_sku) + 1) ELSE NULL END AS tag
|
|
260
|
+
FROM merged m
|
|
261
|
+
),
|
|
262
|
+
with_product AS (
|
|
263
|
+
SELECT
|
|
264
|
+
p2.store_id,
|
|
265
|
+
p2.compound_sku,
|
|
266
|
+
p2.base_sku AS sku,
|
|
267
|
+
p2.tag,
|
|
268
|
+
p2.stock_qty,
|
|
269
|
+
p2.sales_qty_window,
|
|
270
|
+
(p2.sales_qty_window::numeric / ${windowDays}::numeric) AS avg_daily_sales,
|
|
271
|
+
CASE WHEN (p2.sales_qty_window::numeric / ${windowDays}::numeric) > 0
|
|
272
|
+
THEN (p2.stock_qty::numeric / (p2.sales_qty_window::numeric / ${windowDays}::numeric))
|
|
273
|
+
ELSE NULL
|
|
274
|
+
END AS days_cover,
|
|
275
|
+
pr.name AS product_name,
|
|
276
|
+
pr.price,
|
|
277
|
+
pr.discount,
|
|
278
|
+
pr.category_id
|
|
279
|
+
FROM parsed p2
|
|
280
|
+
LEFT JOIN LATERAL (
|
|
281
|
+
SELECT name, price, discount, category_id
|
|
282
|
+
FROM products
|
|
283
|
+
WHERE sku = p2.base_sku
|
|
284
|
+
AND ( (p2.tag IS NOT NULL AND tag = p2.tag) OR (p2.tag IS NULL) )
|
|
285
|
+
ORDER BY
|
|
286
|
+
CASE WHEN p2.tag IS NOT NULL AND tag = p2.tag THEN 0 ELSE 1 END,
|
|
287
|
+
created_at ASC
|
|
288
|
+
LIMIT 1
|
|
289
|
+
) pr ON TRUE
|
|
290
|
+
)
|
|
291
|
+
SELECT *
|
|
292
|
+
FROM with_product
|
|
293
|
+
WHERE (sales_qty_window >= $${params.length + 1})
|
|
294
|
+
${includeZeroSales ? '' : 'AND sales_qty_window > 0'}
|
|
295
|
+
ORDER BY
|
|
296
|
+
CASE WHEN days_cover IS NULL THEN 1 ELSE 0 END,
|
|
297
|
+
days_cover ASC NULLS LAST,
|
|
298
|
+
stock_qty ASC
|
|
299
|
+
LIMIT ${lim} OFFSET ${off}
|
|
300
|
+
`;
|
|
301
|
+
|
|
302
|
+
const res = await query(sql, [...params, Number(minSalesQty || 0)]);
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
window: {
|
|
306
|
+
from: from.toISOString(),
|
|
307
|
+
to: to.toISOString(),
|
|
308
|
+
days: windowDays,
|
|
309
|
+
dateField: field
|
|
310
|
+
},
|
|
311
|
+
rows: res.rows || []
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
},
|
|
315
|
+
|
|
316
|
+
analytics_salesTimeSeries: {
|
|
317
|
+
name: 'analytics.salesTimeSeries',
|
|
318
|
+
description: 'Time series aggregation of orders and items sold over time buckets (day/week/month) with flexible orderScope.',
|
|
319
|
+
schema: z.object({
|
|
320
|
+
dateFrom: z.string().datetime().optional(),
|
|
321
|
+
dateTo: z.string().datetime().optional(),
|
|
322
|
+
dateField: DateFieldSchema,
|
|
323
|
+
bucket: z.enum(['day', 'week', 'month']).default('day'),
|
|
324
|
+
managerId: z.number().optional(),
|
|
325
|
+
orderScope: OrderScopeSchema
|
|
326
|
+
}),
|
|
327
|
+
handler: async (input) => {
|
|
328
|
+
const { dateFrom, dateTo, dateField, bucket, managerId, orderScope } = input;
|
|
329
|
+
|
|
330
|
+
const field = normalizeDateField(dateField);
|
|
331
|
+
const scope = buildOrderScopeWhere(orderScope);
|
|
332
|
+
const params = [...scope.params];
|
|
333
|
+
const clauses = [scope.whereSql];
|
|
334
|
+
|
|
335
|
+
if (dateFrom) {
|
|
336
|
+
params.push(dateFrom);
|
|
337
|
+
clauses.push(`o.${field} >= $${params.length}`);
|
|
338
|
+
}
|
|
339
|
+
if (dateTo) {
|
|
340
|
+
params.push(dateTo);
|
|
341
|
+
clauses.push(`o.${field} <= $${params.length}`);
|
|
342
|
+
}
|
|
343
|
+
if (managerId != null) {
|
|
344
|
+
params.push(Number(managerId));
|
|
345
|
+
clauses.push(`o.manager_id = $${params.length}`);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const where = clauses.join(' AND ');
|
|
349
|
+
const trunc = bucket === 'week' ? 'week' : bucket === 'month' ? 'month' : 'day';
|
|
350
|
+
|
|
351
|
+
const sql = `
|
|
352
|
+
WITH expanded AS (
|
|
353
|
+
SELECT
|
|
354
|
+
o.id AS order_id,
|
|
355
|
+
date_trunc('${trunc}', o.${field}) AS period_start,
|
|
356
|
+
COALESCE(x.quantity, 1) AS qty
|
|
357
|
+
FROM orders o
|
|
358
|
+
LEFT JOIN order_statuses os ON os.id = o.status_id
|
|
359
|
+
JOIN LATERAL jsonb_to_recordset(o.products) AS x(sku text, quantity int) ON TRUE
|
|
360
|
+
WHERE ${where}
|
|
361
|
+
)
|
|
362
|
+
SELECT
|
|
363
|
+
period_start,
|
|
364
|
+
COUNT(DISTINCT order_id)::bigint AS orders_count,
|
|
365
|
+
SUM(qty)::bigint AS items_qty
|
|
366
|
+
FROM expanded
|
|
367
|
+
GROUP BY period_start
|
|
368
|
+
ORDER BY period_start ASC
|
|
369
|
+
`;
|
|
370
|
+
|
|
371
|
+
const res = await query(sql, params);
|
|
372
|
+
return {
|
|
373
|
+
bucket: trunc,
|
|
374
|
+
dateField: field,
|
|
375
|
+
rows: res.rows || []
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
};
|