dashbord-po-zakazam-crm-crm/server.js

115 lines
8.2 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Дашборд по заказам CRM (таблица crm_order в ClickHouse). Сервер на node:http без зависимостей.
// Источник данных: ClickHouse через платформу (CH_BASE_URL/CH_API_KEY появляются при run, если интеграция подключена).
// Если базы нет или запрос упал — демо-данные той же структуры, и в интерфейсе видна плашка «демо».
const http = require("http");
const fs = require("fs");
const path = require("path");
const PORT = process.env.PORT || 3000;
// ---------- запросы к crm_order (готовы к живой базе) ----------
const Q = {
kpi: (f, t) => `SELECT uniqExact(cust_order_id) AS orders,
uniqExactIf(cust_order_id, order_status_name IN ('Закрыт','Выполнен','Завершен','Завершён')) AS closed,
uniqExactIf(cust_order_id, po_reject_reason_name != '' OR order_cancel_reason_id > 0) AS rejected,
uniqExactIf(cust_order_id, install_done IS NOT NULL AND toString(install_done) NOT IN ('', '0')) AS installed
FROM crm_order WHERE toDate(order_create_date) BETWEEN '${f}' AND '${t}'`,
byMonth: (f, t) => `SELECT toStartOfMonth(toDate(order_create_date)) AS m, uniqExact(cust_order_id) AS orders
FROM crm_order WHERE toDate(order_create_date) BETWEEN '${f}' AND '${t}' GROUP BY m ORDER BY m`,
byFilial: (f, t) => `SELECT filial_name AS k, uniqExact(cust_order_id) AS v FROM crm_order
WHERE toDate(order_create_date) BETWEEN '${f}' AND '${t}' GROUP BY k ORDER BY v DESC LIMIT 12`,
byChannel: (f, t) => `SELECT sales_channel_name AS k, uniqExact(cust_order_id) AS v FROM crm_order
WHERE toDate(order_create_date) BETWEEN '${f}' AND '${t}' GROUP BY k ORDER BY v DESC LIMIT 8`,
funnel: (f, t) => `SELECT uniqExact(cust_order_id) AS created,
uniqExactIf(cust_order_id, toString(get_tv) NOT IN ('', '0')) AS tv_requested,
uniqExactIf(cust_order_id, toString(tv_defined) NOT IN ('', '0')) AS tv_defined,
uniqExactIf(cust_order_id, toString(sent_installation) NOT IN ('', '0')) AS sent_install,
uniqExactIf(cust_order_id, toString(install_done) NOT IN ('', '0')) AS installed,
uniqExactIf(cust_order_id, toString(process_done) NOT IN ('', '0')) AS done
FROM crm_order WHERE toDate(order_create_date) BETWEEN '${f}' AND '${t}'`,
topOffers: (f, t) => `SELECT new_product_offer_name AS k, uniqExact(cust_order_id) AS v FROM crm_order
WHERE toDate(order_create_date) BETWEEN '${f}' AND '${t}' AND new_product_offer_name != '' GROUP BY k ORDER BY v DESC LIMIT 10`,
rejects: (f, t) => `SELECT po_reject_reason_name AS k, uniqExact(cust_order_id) AS v FROM crm_order
WHERE toDate(order_create_date) BETWEEN '${f}' AND '${t}' AND po_reject_reason_name != '' GROUP BY k ORDER BY v DESC LIMIT 8`,
byTown: (f, t) => `SELECT town_name AS k, uniqExact(cust_order_id) AS v FROM crm_order
WHERE toDate(order_create_date) BETWEEN '${f}' AND '${t}' AND town_name != '' GROUP BY k ORDER BY v DESC LIMIT 10`,
};
async function ch(sql) {
if (!process.env.CH_BASE_URL || !process.env.CH_API_KEY) throw new Error("ClickHouse не подключён");
const r = await fetch(process.env.CH_BASE_URL + "/query", {
method: "POST", headers: { "Content-Type": "application/json", Authorization: "Bearer " + process.env.CH_API_KEY },
body: JSON.stringify({ sql, max_rows: 5000, timeout: 60 }),
});
const j = await r.json();
if (!j.ok) throw new Error(j.error || "ошибка ClickHouse");
return j.data || [];
}
// ---------- демо-данные той же структуры ----------
const FILIALS = ["Алматинская ОДТ", "Астанинская ОДТ", "Шымкентская ОДТ", "Карагандинская ОДТ", "Актюбинская ОДТ", "Павлодарская ОДТ"];
const CHANNELS = ["Контакт-центр", "Сайт / приложение", "Офис продаж", "Дилеры", "Прямые продажи B2B"];
const OFFERS = ["Интернет 500 Мбит/с + ТВ", "Интернет 200 Мбит/с", "Пакет «Всё включено»", "TV+ базовый", "Бизнес-интернет 1 Гбит/с", "Домашний телефон + интернет"];
const TOWNS = ["Алматы", "Астана", "Шымкент", "Караганда", "Актобе", "Павлодар", "Тараз", "Усть-Каменогорск"];
const REJECTS = ["Нет техвозможности", "Передумал клиент", "Дорого", "Ушёл к конкуренту", "Дубликат заказа", "Не вышли на связь"];
function rng(seed) { let s = seed >>> 0; return () => ((s = (s * 1664525 + 1013904223) >>> 0) / 4294967296); }
function demoRows() {
const r = rng(42); const rows = []; const today = new Date(); today.setHours(0, 0, 0, 0);
for (let i = 0; i < 6000; i++) {
const d = new Date(today); d.setDate(d.getDate() - Math.floor(r() * 180));
const stage = r();
rows.push({ id: 100000 + i, date: d, filial: FILIALS[Math.floor(r() * r() * FILIALS.length)], channel: CHANNELS[Math.floor(r() * r() * CHANNELS.length)],
offer: OFFERS[Math.floor(r() * OFFERS.length)], town: TOWNS[Math.floor(r() * r() * TOWNS.length)],
tv: stage > 0.08, tvdef: stage > 0.18, sent: stage > 0.3, inst: stage > 0.42, done: stage > 0.5,
reject: stage < 0.15 ? REJECTS[Math.floor(r() * REJECTS.length)] : "" });
}
return rows;
}
const DEMO = demoRows();
const iso = (d) => d.toISOString().slice(0, 10);
function demo(f, t) {
const rows = DEMO.filter((x) => { const s = iso(x.date); return s >= f && s <= t; });
const cnt = (key) => { const m = {}; rows.forEach((x) => { const k = x[key]; if (k) m[k] = (m[k] || 0) + 1; }); return Object.entries(m).sort((a, b) => b[1] - a[1]).map(([k, v]) => [k, v]); };
const months = {}; rows.forEach((x) => { const k = iso(x.date).slice(0, 7) + "-01"; months[k] = (months[k] || 0) + 1; });
return {
kpi: [[rows.length, rows.filter((x) => x.done).length, rows.filter((x) => x.reject).length, rows.filter((x) => x.inst).length]],
byMonth: Object.entries(months).sort().map(([m, v]) => [m, v]),
byFilial: cnt("filial"), byChannel: cnt("channel"), topOffers: cnt("offer"), rejects: cnt("reject"), byTown: cnt("town"),
funnel: [[rows.length, rows.filter((x) => x.tv).length, rows.filter((x) => x.tvdef).length, rows.filter((x) => x.sent).length, rows.filter((x) => x.inst).length, rows.filter((x) => x.done).length]],
};
}
const cache = new Map();
async function summary(f, t) {
const key = f + ":" + t; const hit = cache.get(key);
if (hit && Date.now() - hit.at < 120000) return hit.v;
let v;
try {
const out = {};
for (const [k, mk] of Object.entries(Q)) out[k] = await ch(mk(f, t));
v = { source: "clickhouse", database: process.env.CH_DATABASE || "orders", ...out };
} catch (e) {
v = { source: "demo", reason: String(e.message || e), ...demo(f, t) };
}
cache.set(key, { v, at: Date.now() });
return v;
}
const MIME = { ".html": "text/html; charset=utf-8", ".css": "text/css", ".js": "application/javascript", ".svg": "image/svg+xml", ".png": "image/png", ".woff2": "font/woff2", ".woff": "font/woff", ".json": "application/json" };
http.createServer(async (req, res) => {
const u = new URL(req.url, "http://x");
if (u.pathname === "/api/summary") {
const t = u.searchParams.get("to") || iso(new Date());
const f = u.searchParams.get("from") || iso(new Date(Date.now() - 89 * 864e5));
try { const v = await summary(f, t); res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({ ok: true, from: f, to: t, ...v })); }
catch (e) { res.statusCode = 500; res.end(JSON.stringify({ ok: false, error: String(e) })); }
return;
}
let p = u.pathname === "/" ? "/index.html" : u.pathname;
const full = path.join(__dirname, path.normalize(p));
if (full.startsWith(__dirname) && fs.existsSync(full) && fs.statSync(full).isFile()) {
res.setHeader("Content-Type", MIME[path.extname(full)] || "application/octet-stream");
return fs.createReadStream(full).pipe(res);
}
res.statusCode = 404; res.end("not found");
}).listen(PORT, () => console.log("dashboard on " + PORT));