Skip to main content
Glama
Abdulaziz1224

moysklad-analytics-mcp

README.md
# MoySklad Analytics

Interactive dashboard over the MoySklad JSON API 1.2 — sales & profit by product,
stock & restocking, customers & channels, and trends over time.

## Host note

`moysklad.uz` is a localized front-end for the same MoySklad platform — its login
button points at `online.moysklad.ru`, and **there is no `api.moysklad.uz`**
(the hostname does not resolve). Tokens issued through the `.uz` site work
against the standard API host:

```
https://api.moysklad.ru/api/remap/1.2
```

Two quirks worth knowing, both handled in `server/src/moysklad/moysklad.service.ts`:

- The API returns a bare nginx **415** to any client that does not advertise gzip.
- Account limits are **45 requests / 3 seconds** and **5 concurrent**; breaching
  either returns 429 with a `X-RateLimit-Retry-After` header (milliseconds).

## Setup

```bash
pnpm setup                                    # install server + web deps
cp .env.example .env                          # then put your token in it
echo 'MOYSKLAD_TOKEN=xxxxxxxx' > .env
```

Get a token in the MoySklad UI under **Настройки → Обмен данными → Токены
доступа к JSON API**, or:

```bash
curl -X POST https://api.moysklad.ru/api/remap/1.2/security/token \
  -H "Authorization: Basic $(printf 'login:password' | base64)" \
  --compressed
```

## Check the account first

```bash
pnpm probe
```

This validates the token and prints what the account actually holds: identity,
row counts per entity, stores, currency, document volumes, the date range of
real sales, and a reachability check across all 14 report endpoints with sample
row shapes. Run it before trusting any number on the dashboard.

**Permissions matter.** MoySklad hides cost price, purchase price and profit
from users who lack those rights. If the token belongs to a restricted user the
profit and margin columns come back empty — the probe shows this immediately,
and the dashboard labels such panels "нет прав" rather than "нет данных".

## Run

```bash
pnpm dev            # API on :8787, dashboard on :5173
```

Open http://localhost:5173. The browser never sees the token — it talks only to
the Vite proxy, which forwards to the NestJS API.

```bash
pnpm build          # production build of both
pnpm typecheck
```

## Layout

```
server/                     NestJS 11 + TypeScript
  src/moysklad/
    moysklad.service.ts     auth, gzip, pagination, rate limiter, retry
    rate-limiter.ts         40 req/3s, 5 concurrent
    moysklad.types.ts       report row shapes
  src/stats/
    stats.service.ts        report -> dashboard metrics, 5-minute TTL cache
    stats.controller.ts     GET /api/stats/*
    period.dto.ts           query validation
  src/common/
    api-exception.filter.ts upstream errors -> messages the UI can show
  src/probe.ts              account discovery

web/                        Vite 8 + React 19 + HeroUI v3 + Tailwind v4 + Recharts
  src/pages/                Overview, Products, Stock, Customers, Trends
  src/components/           Panel, KpiCard, DataTable, charts, PeriodPicker
  src/lib/                  api client, formatters, types
```

## API

All endpoints take `?from=YYYY-MM-DD&to=YYYY-MM-DD` (default: last 30 days).

| Endpoint | Returns |
|---|---|
| `GET /api/stats/meta` | stores, organizations, default currency |
| `GET /api/stats/overview` | headline KPIs with period-over-period deltas |
| `GET /api/stats/products` | per-product revenue/profit/margin, loss makers, returns, Pareto |
| `GET /api/stats/stock` | stock value, days of cover, restock list, dead & overstocked |
| `GET /api/stats/customers` | top customers, repeat rate, concentration, channels, employees |
| `GET /api/stats/trends?interval=hour\|day\|month` | sales, orders and cash series |
| `POST /api/stats/cache/clear` | drop the TTL cache |
| `GET /api/assistant/status` | whether the assistant has an API key |
| `POST /api/assistant/ask` | SSE stream — `{question, history}` in, `text`/`tool`/`done`/`error` events out |
| `POST /api/mcp` | MCP (Streamable HTTP) for external AI clients — see below |

## Field semantics — verified against the live account

Four things the documentation does not make obvious, each confirmed against real
rows and each a wrong number if handled naively:

- **Money arrives in minor units** and is divided by 100. Confirmed: a bar priced
  at `sellPrice: 9642.86` with `sellSum: 67500` for 7 units is $96.43 each, not
  $9,642.
- **`margin` and `salesMargin` are different ratios, both fractions.**
  `margin` = profit / cost (наценка) and `salesMargin` = profit / revenue
  (маржа). The dashboard shows `salesMargin` as "Маржа" and `margin` as
  "Наценка", each scaled by 100. Rendering the raw `margin` as a percentage
  reports 0.6% where the truth is 60.7%.
- **`/report/stock/all` returns hrefs with a `?expand=supplier` suffix** while
  the profit report returns bare ones. Joining them raw matches nothing and
  silently classifies the entire catalogue as dead stock.
- **The group profit reports count documents, not units** — `salesCount`, not
  `sellQuantity`, which does not exist on them.
- **The money series uses `credit`/`debit`**, not `income`/`outcome`.

Judgement calls that remain yours:

- **Average cheque** divides shipment revenue by shipment count (`/entity/demand`
  in the window), not by customer-order count — the two differ, and mixing them
  made Overview disagree with the Customers page.
- **Days of cover** uses velocity over the selected window only, so a short
  window exaggerates urgency on seasonal items.
- **Dead stock** means zero sales *in the selected window*, not all-time.

## AI assistant

The **Ассистент** tab answers natural-language questions over the same data —
"what should we reorder", "how are treadmills selling", "who are our biggest
customers". Set `DEEPSEEK_API_KEY` ([get one here](https://platform.deepseek.com))
to enable it; without one the tab reports that it is off and nothing else changes.

It runs **DeepSeek** (`deepseek-v4-flash`, OpenAI-compatible API) with function
calling over seven read-only tools that wrap `StatsService`, so the assistant and
the dashboard panels read exactly the same numbers — there is no second
definition of "revenue" to drift. Answers stream to the browser over SSE.

The tools are the assistant's only access to the account: it cannot write to
MoySklad, and it cannot reach anything the dashboard cannot.

Four things worth knowing if you change it:

- **Why this model.** Benchmarked against the alternatives on the real account.
  `deepseek-v4-flash` ($0.14 in / $0.28 out per 1M, $0.0028 on cache hits) scored
  4/4 on tool selection at ~2s per round — cheapest and fastest of everything
  tried. `deepseek-v4-pro` ($0.435/$0.87) was ~2x slower with no accuracy gain.
  Local models were tested too and rejected: see the note below.
- **The balance is prepaid.** A depleted balance returns `402 Insufficient
  Balance`, surfaced in the UI as a readable message rather than a stack trace.
- **Let the tools default the dates.** The prompt tells the model to omit
  `from`/`to` unless the question names a period. Left to itself it computed
  "last 30 days" as a 31-day window — figures correct for its window, but
  silently disagreeing with the dashboard preset for the same phrase.
- **The catalogue is in Russian, and the model is not.** `search_products`
  matches on any single token and, on a miss, returns the account's category
  names so the model can retry with a real term.
- **The field semantics are in the prompt on purpose.** Margin vs markup, the
  average-cheque basis, what "dead stock" counts — the same traps documented
  above. Without them the model reports plausible, wrong numbers.

### Why not a local model

`qwen3:8b` and `qwen3:4b` were benchmarked on the production host via Ollama.
Tool selection was fine (4/4), but the box has no GPU: a single model round took
46–198s warm, so one answer (2–3 rounds) would take 3–5 minutes against ~2s for
DeepSeek. Worse, unconstrained inference took 7.7 of 8 cores and pushed
the production admin panel from 0.5s to 5.5s — the same host runs the API, admin,
bot, Postgres and MinIO. Capping it to 4 cores (`CPUQuota=400%`) protected
production but made inference slower still. Revisit only on a machine with a GPU.
- **The catalogue is in Russian, and the model is not.** Asked about
  "treadmills", every model searched the English word and would have found
  nothing. `search_products` therefore matches on any single token and, on a
  miss, returns the account's category names so the model can retry with a real
  term — and the prompt tells it to search in Russian.
- **The field semantics are in the prompt on purpose.** Margin vs markup, the
  average-cheque basis, what "dead stock" counts — the same traps documented
  above. Without them the model reports plausible, wrong numbers.

## MCP endpoint — the same data for external AI

`POST /api/mcp` speaks [MCP](https://modelcontextprotocol.io) over Streamable
HTTP (stateless, JSON responses), so any MCP client — Claude, Cursor, ChatGPT
connectors, another agent — can query the account directly.

The tool surface is the assistant's seven, plus seven business tools, plus raw:

- the **seven curated analytics tools** (`get_account_info`, `get_overview`,
  `search_products`, `get_products`, `get_stock`, `get_customers`,
  `get_trends`) — the same `buildTools(stats)` objects the in-dashboard
  assistant uses, so an external model and the dashboard cannot disagree on
  what "revenue" means;
- **seven business tools** (`server/src/mcp/business-tools.ts`), shaped by
  probing what the account actually holds: `get_documents` (any of 16 document
  types for a period — orders, shipments, cash, write-offs, transfers, ...),
  `get_document` (one document with line items), `get_money` (balances per
  org/account, cash vs bank flow, expenses by category — the cash desk is this
  account's busiest surface), `get_purchases` (supplies + purchase orders by
  supplier), `get_debts` (mutual settlements both directions),
  `search_counterparties` (lookup + per-counterparty history), `get_turnover`
  (opening/received/issued/closing per product). All money already in major
  units;
- **`moysklad_api`** — raw read-only GET against any `entity/...` or
  `report/...` path, for everything the curated tools don't cover
  (counterparties, purchase documents, cash flow, single objects by id).
  Structurally read-only: the underlying client only issues GET. Results are
  capped at 100 rows / ~60k characters and `meta` objects are trimmed to
  `href`+`type`. **Raw responses are in minor units (kopecks/tiyin)** — the
  tool description tells the model to divide by 100; the curated tools already
  return major units.

Enable it by setting `MCP_TOKEN` (min 24 chars, `openssl rand -hex 32`).
Without `MCP_TOKEN` the route simply stays behind Basic auth like everything
else. With it, clients get in two ways:

**1. Static bearer** — for Claude Code and scripts:

```bash
claude mcp add moysklad --transport http https://your-deployment.example.com/api/mcp \
  --header "Authorization: Bearer <MCP_TOKEN>"
```

**2. OAuth login** — for the claude.ai and ChatGPT apps (including mobile),
whose connector UIs cannot send custom headers. Add a custom connector with
just the URL `https://your-deployment.example.com/api/mcp`; the app discovers the
OAuth endpoints, opens the login page, and the user signs in with the
**dashboard credentials** (`DASHBOARD_USER` / `DASHBOARD_PASSWORD`). In
claude.ai: Settings → Connectors → Add custom connector (connectors added on
the web are then available in the mobile app). In ChatGPT: Settings →
Connectors (Developer mode required for custom MCP).

The OAuth server is the full MCP-spec stack — RFC 9728 resource metadata,
RFC 8414 AS metadata, RFC 7591 dynamic client registration, authorization code
+ PKCE (S256, enforced), refresh tokens — implemented in
`server/src/oauth/`. Design choices worth knowing:

- **Stateless everything.** Codes, tokens and client registrations are
  HMAC-signed blobs; there is no database, and a redeploy logs nobody out.
  The signing key is `MCP_TOKEN` itself, so rotating that one value revokes
  the static credential *and* every OAuth grant at once.
- **One human identity.** The login form checks the dashboard's Basic-auth
  credentials — same person, same password, however they arrive.
- **Access tokens live 30 days, refresh 180.** The only server-side state is
  the used-code set (single-use enforcement); losing it on restart is a
  10-minute replay window, accepted.
- **Login is throttled** globally (20 failures/minute), not per-IP — behind
  the proxy every client is one IP anyway.

`PUBLIC_URL` pins the origin used in OAuth metadata; unset, it is derived from
`Host`/`X-Forwarded-Proto`, which is correct behind Caddy.

## Authentication

The server enforces HTTP Basic auth and **refuses to start when
`NODE_ENV=production` without `DASHBOARD_USER` and `DASHBOARD_PASSWORD`**. Only
`/api/health` is exempt, and it reports nothing but liveness. `/api/mcp` has
its own Bearer token (above) and is exempt from Basic auth only when that token
is configured.

This is not optional hardening. The dashboard shows revenue, cost prices,
per-product margins and every customer with what they spend; on a public host an
unguarded URL is not obscure, because the certificate transparency log publishes
the hostname minutes after Caddy issues the certificate.

In development, omitting the variables runs without auth and logs a warning.

## Deployment


```bash
docker build -t moysklad-stats:latest .     # API + built SPA in one image
```