@optima-chat/bi-cli 0.2.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/dist/commands/analytics.d.ts +3 -0
- package/dist/commands/analytics.d.ts.map +1 -0
- package/dist/commands/analytics.js +228 -0
- package/dist/commands/analytics.js.map +1 -0
- package/dist/commands/auth.d.ts +3 -0
- package/dist/commands/auth.d.ts.map +1 -0
- package/dist/commands/auth.js +214 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/product.d.ts +3 -0
- package/dist/commands/product.d.ts.map +1 -0
- package/dist/commands/product.js +199 -0
- package/dist/commands/product.js.map +1 -0
- package/dist/commands/sales.d.ts +3 -0
- package/dist/commands/sales.d.ts.map +1 -0
- package/dist/commands/sales.js +85 -0
- package/dist/commands/sales.js.map +1 -0
- package/dist/commands/trends.d.ts +3 -0
- package/dist/commands/trends.d.ts.map +1 -0
- package/dist/commands/trends.js +224 -0
- package/dist/commands/trends.js.map +1 -0
- package/dist/config/index.d.ts +21 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +39 -0
- package/dist/config/index.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +52 -0
- package/dist/index.js.map +1 -0
- package/dist/utils/output.d.ts +11 -0
- package/dist/utils/output.d.ts.map +1 -0
- package/dist/utils/output.js +45 -0
- package/dist/utils/output.js.map +1 -0
- package/package.json +39 -0
- package/src/commands/analytics.ts +352 -0
- package/src/commands/auth.ts +277 -0
- package/src/commands/product.ts +327 -0
- package/src/commands/sales.ts +125 -0
- package/src/commands/trends.ts +355 -0
- package/src/config/index.ts +50 -0
- package/src/index.ts +64 -0
- package/src/utils/output.ts +52 -0
- package/test-auth.js +63 -0
- package/test-sales.js +45 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import axios from 'axios';
|
|
3
|
+
import ora from 'ora';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { setConfig, clearAuth, getConfig } from '../config/index.js';
|
|
6
|
+
import { success, error, info } from '../utils/output.js';
|
|
7
|
+
|
|
8
|
+
interface DeviceCodeResponse {
|
|
9
|
+
device_code: string;
|
|
10
|
+
user_code: string;
|
|
11
|
+
verification_uri: string;
|
|
12
|
+
verification_uri_complete?: string;
|
|
13
|
+
expires_in: number;
|
|
14
|
+
interval: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface TokenResponse {
|
|
18
|
+
access_token: string;
|
|
19
|
+
refresh_token: string;
|
|
20
|
+
expires_in: number;
|
|
21
|
+
token_type: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface UserInfo {
|
|
25
|
+
id: string;
|
|
26
|
+
email: string;
|
|
27
|
+
role: 'merchant' | 'admin';
|
|
28
|
+
merchant_id?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createAuthCommand(): Command {
|
|
32
|
+
const auth = new Command('auth').description('Authentication commands');
|
|
33
|
+
|
|
34
|
+
// auth login
|
|
35
|
+
auth
|
|
36
|
+
.command('login')
|
|
37
|
+
.description('Login with OAuth 2.0 Device Flow')
|
|
38
|
+
.option('--env <environment>', 'Environment (production|stage|development)', 'production')
|
|
39
|
+
.action(async (options) => {
|
|
40
|
+
const { env } = options;
|
|
41
|
+
|
|
42
|
+
// Set URLs based on environment
|
|
43
|
+
const authUrls = {
|
|
44
|
+
production: 'https://auth.optima.chat',
|
|
45
|
+
stage: 'https://auth-stage.optima.chat',
|
|
46
|
+
development: 'http://localhost:4000',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const backendUrls = {
|
|
50
|
+
production: 'https://bi-api.optima.chat',
|
|
51
|
+
stage: 'https://bi-api-stage.optima.chat',
|
|
52
|
+
development: 'http://localhost:3001',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const authUrl = authUrls[env as keyof typeof authUrls];
|
|
56
|
+
const backendUrl = backendUrls[env as keyof typeof backendUrls];
|
|
57
|
+
|
|
58
|
+
if (!authUrl || !backendUrl) {
|
|
59
|
+
error(`Invalid environment: ${env}`);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
setConfig('environment', env);
|
|
64
|
+
setConfig('authUrl', authUrl);
|
|
65
|
+
setConfig('backendUrl', backendUrl);
|
|
66
|
+
|
|
67
|
+
info(`Logging in to ${env} environment...`);
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
// Step 1: Request device code
|
|
71
|
+
const spinner = ora('Requesting device code...').start();
|
|
72
|
+
const deviceCodeRes = await axios.post<DeviceCodeResponse>(
|
|
73
|
+
`${authUrl}/api/v1/oauth/device/authorize`,
|
|
74
|
+
{ client_id: 'bi-cli-aqkutatj' }
|
|
75
|
+
);
|
|
76
|
+
spinner.succeed('Device code received');
|
|
77
|
+
|
|
78
|
+
const {
|
|
79
|
+
device_code,
|
|
80
|
+
user_code,
|
|
81
|
+
verification_uri,
|
|
82
|
+
verification_uri_complete,
|
|
83
|
+
expires_in,
|
|
84
|
+
interval,
|
|
85
|
+
} = deviceCodeRes.data;
|
|
86
|
+
|
|
87
|
+
// Use verification_uri_complete if available (includes code pre-filled)
|
|
88
|
+
const browserUrl = verification_uri_complete || verification_uri;
|
|
89
|
+
|
|
90
|
+
// Step 2: Display authorization instructions
|
|
91
|
+
console.log(chalk.bold('\n📋 Authorization Required:\n'));
|
|
92
|
+
if (verification_uri_complete) {
|
|
93
|
+
console.log(` Opening browser with pre-filled code: ${chalk.yellow.bold(user_code)}`);
|
|
94
|
+
console.log(` URL: ${chalk.cyan(verification_uri_complete)}\n`);
|
|
95
|
+
} else {
|
|
96
|
+
console.log(` 1. Visit: ${chalk.cyan(verification_uri)}`);
|
|
97
|
+
console.log(` 2. Enter code: ${chalk.yellow.bold(user_code)}\n`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Step 3: Open browser automatically
|
|
101
|
+
const { default: open } = await import('open');
|
|
102
|
+
await open(browserUrl);
|
|
103
|
+
info('Browser opened automatically');
|
|
104
|
+
|
|
105
|
+
// Step 4: Poll for token
|
|
106
|
+
const pollSpinner = ora('Waiting for authorization...').start();
|
|
107
|
+
const startTime = Date.now();
|
|
108
|
+
const expiresAt = startTime + expires_in * 1000;
|
|
109
|
+
|
|
110
|
+
let token: TokenResponse | null = null;
|
|
111
|
+
let pollCount = 0;
|
|
112
|
+
|
|
113
|
+
while (Date.now() < expiresAt) {
|
|
114
|
+
await new Promise((resolve) => setTimeout(resolve, interval * 1000));
|
|
115
|
+
pollCount++;
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const tokenRes = await axios.post<
|
|
119
|
+
TokenResponse | { error: string; error_description: string }
|
|
120
|
+
>(
|
|
121
|
+
`${authUrl}/api/v1/oauth/device/token`,
|
|
122
|
+
new URLSearchParams({
|
|
123
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
124
|
+
client_id: 'bi-cli-aqkutatj',
|
|
125
|
+
device_code,
|
|
126
|
+
}),
|
|
127
|
+
{
|
|
128
|
+
headers: {
|
|
129
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// Check if response contains an error (auth server returns 200 with error object)
|
|
135
|
+
if ('error' in tokenRes.data) {
|
|
136
|
+
const errorCode = tokenRes.data.error;
|
|
137
|
+
if (errorCode === 'authorization_pending') {
|
|
138
|
+
pollSpinner.text = `Waiting for authorization... (attempt ${pollCount})`;
|
|
139
|
+
continue;
|
|
140
|
+
} else if (errorCode === 'slow_down') {
|
|
141
|
+
pollSpinner.text = `Slowing down polling... (attempt ${pollCount})`;
|
|
142
|
+
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
143
|
+
continue;
|
|
144
|
+
} else {
|
|
145
|
+
pollSpinner.fail(`Polling failed: ${errorCode}`);
|
|
146
|
+
throw new Error(tokenRes.data.error_description);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
token = tokenRes.data as TokenResponse;
|
|
151
|
+
pollSpinner.text = `Authorization successful after ${pollCount} attempts`;
|
|
152
|
+
break;
|
|
153
|
+
} catch (err: unknown) {
|
|
154
|
+
const error = err as { response?: { data?: { error?: string } } };
|
|
155
|
+
const errorCode = error.response?.data?.error;
|
|
156
|
+
|
|
157
|
+
if (errorCode === 'authorization_pending') {
|
|
158
|
+
// Continue polling
|
|
159
|
+
pollSpinner.text = `Waiting for authorization... (attempt ${pollCount})`;
|
|
160
|
+
continue;
|
|
161
|
+
} else if (errorCode === 'slow_down') {
|
|
162
|
+
// Increase interval
|
|
163
|
+
pollSpinner.text = `Slowing down polling... (attempt ${pollCount})`;
|
|
164
|
+
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
165
|
+
continue;
|
|
166
|
+
} else {
|
|
167
|
+
// Unexpected error
|
|
168
|
+
pollSpinner.fail(`Polling failed: ${errorCode || 'unknown error'}`);
|
|
169
|
+
throw err;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!token) {
|
|
175
|
+
pollSpinner.fail('Authorization timeout');
|
|
176
|
+
error('Please try again');
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
pollSpinner.succeed();
|
|
181
|
+
|
|
182
|
+
// Step 5: Save tokens
|
|
183
|
+
setConfig('accessToken', token.access_token);
|
|
184
|
+
setConfig('refreshToken', token.refresh_token);
|
|
185
|
+
|
|
186
|
+
// Step 6: Fetch user info (optional - just for display)
|
|
187
|
+
try {
|
|
188
|
+
const userInfo = await axios.get<UserInfo>(`${authUrl}/api/v1/users/me`, {
|
|
189
|
+
headers: { Authorization: `Bearer ${token.access_token}` },
|
|
190
|
+
});
|
|
191
|
+
success(`Logged in as ${chalk.bold(userInfo.data.email)} (${userInfo.data.role})`);
|
|
192
|
+
} catch (userInfoErr) {
|
|
193
|
+
// If fetching user info fails, still consider login successful
|
|
194
|
+
success('Login successful! Token saved.');
|
|
195
|
+
}
|
|
196
|
+
} catch (err: unknown) {
|
|
197
|
+
const errorMsg = err instanceof Error ? err.message : 'Unknown error';
|
|
198
|
+
error(`Login failed: ${errorMsg}`);
|
|
199
|
+
process.exit(1);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// auth logout
|
|
204
|
+
auth
|
|
205
|
+
.command('logout')
|
|
206
|
+
.description('Logout and clear stored credentials')
|
|
207
|
+
.action(() => {
|
|
208
|
+
clearAuth();
|
|
209
|
+
success('Logged out successfully');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// auth whoami
|
|
213
|
+
auth
|
|
214
|
+
.command('whoami')
|
|
215
|
+
.description('Show current user information')
|
|
216
|
+
.action(async () => {
|
|
217
|
+
const cfg = getConfig();
|
|
218
|
+
|
|
219
|
+
if (!cfg.accessToken) {
|
|
220
|
+
error('Not logged in. Run: bi-cli auth login');
|
|
221
|
+
process.exit(1);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
const userInfo = await axios.get<UserInfo>(`${cfg.authUrl}/api/v1/users/me`, {
|
|
226
|
+
headers: { Authorization: `Bearer ${cfg.accessToken}` },
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
console.log(chalk.bold('\n👤 Current User:\n'));
|
|
230
|
+
console.log(` Email: ${chalk.cyan(userInfo.data.email)}`);
|
|
231
|
+
console.log(` Role: ${chalk.yellow(userInfo.data.role)}`);
|
|
232
|
+
if (userInfo.data.merchant_id) {
|
|
233
|
+
console.log(` Merchant ID: ${chalk.gray(userInfo.data.merchant_id)}`);
|
|
234
|
+
}
|
|
235
|
+
console.log(` Environment: ${chalk.green(cfg.environment)}\n`);
|
|
236
|
+
} catch (err: unknown) {
|
|
237
|
+
const axiosError = err as { response?: { status?: number } };
|
|
238
|
+
if (axiosError.response?.status === 401) {
|
|
239
|
+
error('Token expired. Please login again: bi-cli auth login');
|
|
240
|
+
} else {
|
|
241
|
+
const errorMsg = err instanceof Error ? err.message : 'Unknown error';
|
|
242
|
+
error(`Failed to fetch user info: ${errorMsg}`);
|
|
243
|
+
}
|
|
244
|
+
process.exit(1);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// auth switch
|
|
249
|
+
auth
|
|
250
|
+
.command('switch')
|
|
251
|
+
.description('Switch environment')
|
|
252
|
+
.option('--env <environment>', 'Environment (production|stage|development)', 'production')
|
|
253
|
+
.action((options) => {
|
|
254
|
+
const { env } = options;
|
|
255
|
+
|
|
256
|
+
const backendUrls = {
|
|
257
|
+
production: 'https://bi-api.optima.chat',
|
|
258
|
+
stage: 'https://bi-api-stage.optima.chat',
|
|
259
|
+
development: 'http://localhost:3001',
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const backendUrl = backendUrls[env as keyof typeof backendUrls];
|
|
263
|
+
if (!backendUrl) {
|
|
264
|
+
error(`Invalid environment: ${env}`);
|
|
265
|
+
process.exit(1);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
setConfig('environment', env);
|
|
269
|
+
setConfig('backendUrl', backendUrl);
|
|
270
|
+
clearAuth(); // Clear tokens when switching environment
|
|
271
|
+
|
|
272
|
+
success(`Switched to ${env} environment`);
|
|
273
|
+
info('Please login again: bi-cli auth login');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
return auth;
|
|
277
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import axios from 'axios';
|
|
3
|
+
import { getConfig } from '../config/index.js';
|
|
4
|
+
import { outputJson, outputPretty, error, OutputFormat, info } from '../utils/output.js';
|
|
5
|
+
|
|
6
|
+
interface BestSellersResponse {
|
|
7
|
+
success: boolean;
|
|
8
|
+
data: {
|
|
9
|
+
merchant_id: string;
|
|
10
|
+
sort_by: string;
|
|
11
|
+
products: Array<{
|
|
12
|
+
rank: number;
|
|
13
|
+
title: string;
|
|
14
|
+
revenue: number;
|
|
15
|
+
units_sold: number;
|
|
16
|
+
orders: number;
|
|
17
|
+
avg_price: number;
|
|
18
|
+
revenue_share: number;
|
|
19
|
+
}>;
|
|
20
|
+
total_revenue: number;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface ABCAnalysisResponse {
|
|
25
|
+
success: boolean;
|
|
26
|
+
data: {
|
|
27
|
+
merchant_id: string;
|
|
28
|
+
total_revenue: number;
|
|
29
|
+
summary: {
|
|
30
|
+
A: { description: string; count: number; revenue: number; percent_of_products: number };
|
|
31
|
+
B: { description: string; count: number; revenue: number; percent_of_products: number };
|
|
32
|
+
C: { description: string; count: number; revenue: number; percent_of_products: number };
|
|
33
|
+
};
|
|
34
|
+
products: Array<{
|
|
35
|
+
title: string;
|
|
36
|
+
revenue: number;
|
|
37
|
+
units: number;
|
|
38
|
+
revenue_percent: number;
|
|
39
|
+
cumulative_percent: number;
|
|
40
|
+
category: 'A' | 'B' | 'C';
|
|
41
|
+
}>;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface PriceAnalysisResponse {
|
|
46
|
+
success: boolean;
|
|
47
|
+
data: {
|
|
48
|
+
merchant_id: string;
|
|
49
|
+
price_ranges: Array<{
|
|
50
|
+
range: string;
|
|
51
|
+
products: number;
|
|
52
|
+
revenue: number;
|
|
53
|
+
units: number;
|
|
54
|
+
avg_price: number;
|
|
55
|
+
revenue_share: number;
|
|
56
|
+
units_share: number;
|
|
57
|
+
}>;
|
|
58
|
+
totals: {
|
|
59
|
+
revenue: number;
|
|
60
|
+
units: number;
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface PerformanceResponse {
|
|
66
|
+
success: boolean;
|
|
67
|
+
data: {
|
|
68
|
+
merchant_id: string;
|
|
69
|
+
period: { days: number };
|
|
70
|
+
products: Array<{
|
|
71
|
+
title: string;
|
|
72
|
+
price: number;
|
|
73
|
+
cost: number | null;
|
|
74
|
+
inventory: number;
|
|
75
|
+
status: string;
|
|
76
|
+
sales: {
|
|
77
|
+
units: number;
|
|
78
|
+
revenue: number;
|
|
79
|
+
orders: number;
|
|
80
|
+
};
|
|
81
|
+
metrics: {
|
|
82
|
+
margin_percent: number;
|
|
83
|
+
days_of_stock: number;
|
|
84
|
+
velocity: number;
|
|
85
|
+
};
|
|
86
|
+
}>;
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function createProductCommand(): Command {
|
|
91
|
+
const product = new Command('product').description('Product analytics');
|
|
92
|
+
|
|
93
|
+
// product best-sellers
|
|
94
|
+
product
|
|
95
|
+
.command('best-sellers')
|
|
96
|
+
.description('Get best selling products')
|
|
97
|
+
.option('--limit <number>', 'Number of products to return', '10')
|
|
98
|
+
.option('--sort <field>', 'Sort by: revenue, quantity, orders', 'revenue')
|
|
99
|
+
.option('--pretty', 'Output in pretty table format')
|
|
100
|
+
.action(async (options) => {
|
|
101
|
+
const cfg = getConfig();
|
|
102
|
+
if (!cfg.accessToken) {
|
|
103
|
+
error('Not logged in. Run: bi-cli auth login');
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const format = options.pretty ? OutputFormat.PRETTY : OutputFormat.JSON;
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const response = await axios.get<BestSellersResponse>(
|
|
111
|
+
`${cfg.backendUrl}/api/v1/products/best-sellers`,
|
|
112
|
+
{
|
|
113
|
+
params: { limit: options.limit, sort_by: options.sort },
|
|
114
|
+
headers: { Authorization: `Bearer ${cfg.accessToken}` },
|
|
115
|
+
}
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
if (!response.data.success) {
|
|
119
|
+
error('Failed to fetch best sellers');
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const { products, total_revenue } = response.data.data;
|
|
124
|
+
|
|
125
|
+
if (format === OutputFormat.JSON) {
|
|
126
|
+
outputJson(response.data.data);
|
|
127
|
+
} else {
|
|
128
|
+
console.log('\n🏆 Best Selling Products\n');
|
|
129
|
+
outputPretty(
|
|
130
|
+
products.map((p) => ({
|
|
131
|
+
Rank: p.rank,
|
|
132
|
+
Title: p.title.length > 30 ? p.title.slice(0, 27) + '...' : p.title,
|
|
133
|
+
Revenue: `¥${p.revenue.toFixed(2)}`,
|
|
134
|
+
Units: p.units_sold,
|
|
135
|
+
Orders: p.orders,
|
|
136
|
+
Share: `${p.revenue_share.toFixed(1)}%`,
|
|
137
|
+
})),
|
|
138
|
+
['Rank', 'Title', 'Revenue', 'Units', 'Orders', 'Share']
|
|
139
|
+
);
|
|
140
|
+
info(`Total Revenue: ¥${total_revenue.toFixed(2)}`);
|
|
141
|
+
}
|
|
142
|
+
} catch (err: unknown) {
|
|
143
|
+
handleApiError(err);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// product abc-analysis
|
|
148
|
+
product
|
|
149
|
+
.command('abc-analysis')
|
|
150
|
+
.description('ABC inventory analysis')
|
|
151
|
+
.option('--pretty', 'Output in pretty table format')
|
|
152
|
+
.action(async (options) => {
|
|
153
|
+
const cfg = getConfig();
|
|
154
|
+
if (!cfg.accessToken) {
|
|
155
|
+
error('Not logged in. Run: bi-cli auth login');
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const format = options.pretty ? OutputFormat.PRETTY : OutputFormat.JSON;
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const response = await axios.get<ABCAnalysisResponse>(
|
|
163
|
+
`${cfg.backendUrl}/api/v1/products/abc-analysis`,
|
|
164
|
+
{
|
|
165
|
+
headers: { Authorization: `Bearer ${cfg.accessToken}` },
|
|
166
|
+
}
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
if (!response.data.success) {
|
|
170
|
+
error('Failed to fetch ABC analysis');
|
|
171
|
+
process.exit(1);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const { summary, products, total_revenue } = response.data.data;
|
|
175
|
+
|
|
176
|
+
if (format === OutputFormat.JSON) {
|
|
177
|
+
outputJson(response.data.data);
|
|
178
|
+
} else {
|
|
179
|
+
console.log('\n📊 ABC Analysis Summary\n');
|
|
180
|
+
outputPretty({
|
|
181
|
+
'Category A (High Value)': `${summary.A.count} products (${summary.A.percent_of_products.toFixed(1)}%) - ¥${summary.A.revenue.toFixed(2)}`,
|
|
182
|
+
'Category B (Medium Value)': `${summary.B.count} products (${summary.B.percent_of_products.toFixed(1)}%) - ¥${summary.B.revenue.toFixed(2)}`,
|
|
183
|
+
'Category C (Low Value)': `${summary.C.count} products (${summary.C.percent_of_products.toFixed(1)}%) - ¥${summary.C.revenue.toFixed(2)}`,
|
|
184
|
+
'Total Revenue': `¥${total_revenue.toFixed(2)}`,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
console.log('\n📦 Top Products by Category\n');
|
|
188
|
+
const topProducts = products.slice(0, 15);
|
|
189
|
+
outputPretty(
|
|
190
|
+
topProducts.map((p) => ({
|
|
191
|
+
Category: p.category,
|
|
192
|
+
Title: p.title.length > 25 ? p.title.slice(0, 22) + '...' : p.title,
|
|
193
|
+
Revenue: `¥${p.revenue.toFixed(2)}`,
|
|
194
|
+
Units: p.units,
|
|
195
|
+
'Revenue %': `${p.revenue_percent.toFixed(1)}%`,
|
|
196
|
+
})),
|
|
197
|
+
['Category', 'Title', 'Revenue', 'Units', 'Revenue %']
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
} catch (err: unknown) {
|
|
201
|
+
handleApiError(err);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// product price-analysis
|
|
206
|
+
product
|
|
207
|
+
.command('price-analysis')
|
|
208
|
+
.description('Price point analysis')
|
|
209
|
+
.option('--pretty', 'Output in pretty table format')
|
|
210
|
+
.action(async (options) => {
|
|
211
|
+
const cfg = getConfig();
|
|
212
|
+
if (!cfg.accessToken) {
|
|
213
|
+
error('Not logged in. Run: bi-cli auth login');
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const format = options.pretty ? OutputFormat.PRETTY : OutputFormat.JSON;
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
const response = await axios.get<PriceAnalysisResponse>(
|
|
221
|
+
`${cfg.backendUrl}/api/v1/products/price-analysis`,
|
|
222
|
+
{
|
|
223
|
+
headers: { Authorization: `Bearer ${cfg.accessToken}` },
|
|
224
|
+
}
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
if (!response.data.success) {
|
|
228
|
+
error('Failed to fetch price analysis');
|
|
229
|
+
process.exit(1);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const { price_ranges, totals } = response.data.data;
|
|
233
|
+
|
|
234
|
+
if (format === OutputFormat.JSON) {
|
|
235
|
+
outputJson(response.data.data);
|
|
236
|
+
} else {
|
|
237
|
+
console.log('\n💰 Price Distribution Analysis\n');
|
|
238
|
+
outputPretty(
|
|
239
|
+
price_ranges.map((r) => ({
|
|
240
|
+
Range: r.range,
|
|
241
|
+
Products: r.products,
|
|
242
|
+
Revenue: `¥${r.revenue.toFixed(2)}`,
|
|
243
|
+
Units: r.units,
|
|
244
|
+
'Avg Price': `¥${r.avg_price.toFixed(2)}`,
|
|
245
|
+
'Revenue Share': `${r.revenue_share.toFixed(1)}%`,
|
|
246
|
+
})),
|
|
247
|
+
['Range', 'Products', 'Revenue', 'Units', 'Avg Price', 'Revenue Share']
|
|
248
|
+
);
|
|
249
|
+
info(`Total Revenue: ¥${totals.revenue.toFixed(2)} | Total Units: ${totals.units}`);
|
|
250
|
+
}
|
|
251
|
+
} catch (err: unknown) {
|
|
252
|
+
handleApiError(err);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// product performance
|
|
257
|
+
product
|
|
258
|
+
.command('performance')
|
|
259
|
+
.description('Get product performance metrics')
|
|
260
|
+
.option('--days <number>', 'Number of days', '30')
|
|
261
|
+
.option('--limit <number>', 'Number of products', '20')
|
|
262
|
+
.option('--pretty', 'Output in pretty table format')
|
|
263
|
+
.action(async (options) => {
|
|
264
|
+
const cfg = getConfig();
|
|
265
|
+
if (!cfg.accessToken) {
|
|
266
|
+
error('Not logged in. Run: bi-cli auth login');
|
|
267
|
+
process.exit(1);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const format = options.pretty ? OutputFormat.PRETTY : OutputFormat.JSON;
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
const response = await axios.get<PerformanceResponse>(
|
|
274
|
+
`${cfg.backendUrl}/api/v1/products/performance`,
|
|
275
|
+
{
|
|
276
|
+
params: { days: options.days, limit: options.limit },
|
|
277
|
+
headers: { Authorization: `Bearer ${cfg.accessToken}` },
|
|
278
|
+
}
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
if (!response.data.success) {
|
|
282
|
+
error('Failed to fetch product performance');
|
|
283
|
+
process.exit(1);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const { products, period } = response.data.data;
|
|
287
|
+
|
|
288
|
+
if (format === OutputFormat.JSON) {
|
|
289
|
+
outputJson(response.data.data);
|
|
290
|
+
} else {
|
|
291
|
+
console.log(`\n📈 Product Performance (Last ${period.days} days)\n`);
|
|
292
|
+
outputPretty(
|
|
293
|
+
products.map((p) => ({
|
|
294
|
+
Title: p.title.length > 20 ? p.title.slice(0, 17) + '...' : p.title,
|
|
295
|
+
Price: `¥${p.price.toFixed(2)}`,
|
|
296
|
+
Revenue: `¥${p.sales.revenue.toFixed(2)}`,
|
|
297
|
+
Units: p.sales.units,
|
|
298
|
+
Stock: p.inventory,
|
|
299
|
+
'Days Stock': p.metrics.days_of_stock.toFixed(0),
|
|
300
|
+
'Margin %': `${p.metrics.margin_percent.toFixed(1)}%`,
|
|
301
|
+
})),
|
|
302
|
+
['Title', 'Price', 'Revenue', 'Units', 'Stock', 'Days Stock', 'Margin %']
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
} catch (err: unknown) {
|
|
306
|
+
handleApiError(err);
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
return product;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function handleApiError(err: unknown): never {
|
|
314
|
+
const axiosError = err as {
|
|
315
|
+
response?: { status?: number; data?: { error?: { message?: string } } };
|
|
316
|
+
};
|
|
317
|
+
const errorObj = err as Error;
|
|
318
|
+
|
|
319
|
+
if (axiosError.response?.status === 401) {
|
|
320
|
+
error('Authentication failed. Please login again: bi-cli auth login');
|
|
321
|
+
} else if (axiosError.response?.data?.error) {
|
|
322
|
+
error(`Error: ${axiosError.response.data.error.message}`);
|
|
323
|
+
} else {
|
|
324
|
+
error(`Request failed: ${errorObj.message || 'Unknown error'}`);
|
|
325
|
+
}
|
|
326
|
+
process.exit(1);
|
|
327
|
+
}
|