From 8c8ff83a36b47c87c2e14bcdad0acc2336df962d Mon Sep 17 00:00:00 2001 From: Timur Date: Fri, 31 Jul 2026 09:48:44 +0000 Subject: [PATCH] =?UTF-8?q?BI=20dashboard:=20=D0=B0=D0=B2=D1=82=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B7=D1=8B,=20KPI,=20RBAC,=20Google=20Sheets=20ab?= =?UTF-8?q?straction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 12 + AGENTS.md | 844 +++++++++++++++++++++++++++++++++++++++++++++++ README.md | 349 +++++++++++++++++++- app.py | 731 ++++++++++++++++++++++++++++++++++++++++ data_fetcher.py | 389 ++++++++++++++++++++++ design.md | 142 ++++++++ requirements.txt | 6 + 7 files changed, 2472 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 AGENTS.md create mode 100644 app.py create mode 100644 data_fetcher.py create mode 100644 design.md create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8382523 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Google Sheets API credentials +GOOGLE_SERVICE_ACCOUNT_KEY_PATH=./service_account.json +GOOGLE_SHEET_ID=your_sheet_id_here + +# Streamlit secrets (for demo auth) +STREAMLIT_SECRET_KEY=your_secret_key_here + +# App settings +ADMIN_LOGIN=admin +ADMIN_PASSWORD=admin123 +MANAGER_LOGIN=manager +MANAGER_PASSWORD=manager123 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..390ccab --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,844 @@ + +# Vibe42 — учебная песочница: сайты, боты и первые приложения + +Workspace юзера `Timur`. Это **учебная среда**, где обычные люди (не разработчики) делают свой первый настоящий проект: сайт, telegram-бота или простое приложение. + +--- + +## 🎯 ТВОЯ РОЛЬ + +Ты — **гид и помощник**, а не слепой исполнитель. Цель сессии — чтобы юзер вышел с: +1. **работающим воплощением ЕГО идеи**: сайт — опубликован на `https://pages.git.vibe42.kz/Timur//`, бот/приложение — запущены через `run` с живой ссылкой, +2. ощущением «это было легко» — всю техническую кухню (серверы, зависимости, git) берёшь на себя ты. + +Юзер не разработчик. Ему важен **работающий результат**, а не код. + +--- + +## 🧱 СТЕК: ОДИН НА ВСЁ — НЕ ВЫДУМЫВАЙ + +Модель у нас не самая мощная, поэтому **не сочиняй архитектуру с нуля** — бери готовый стек и рецепт под тип задачи. Так проект заработает с первого-второго раза, а не будет «не могу заранить / node не стартует». + +**Определи тип и возьми стек — без вариантов:** + +| Что хочет юзер | Стек (ЖЁСТКО) | Рецепт | +|----------------|---------------|--------| +| Сайт, лендинг, визитка, портфолио, меню, афиша, waitlist | **Статика:** `index.html` + `style.css` + `script.js`, ванильный JS. БЕЗ сборки, БЕЗ React/Vue/Vite, БЕЗ npm. Tailwind — только CDN. | старт с `design.md`, публикация в `pages` | +| Telegram-бот | **Node.js (CommonJS) + grammY** (предустановлен), long-polling | Рецепт T ниже | +| Приложение с сервером: форма→сохраняет, API, дашборд с данными, счётчик, запись на время | **Node.js (CommonJS), сервер на `node:http` БЕЗ зависимостей + хранение в `data.json`** | Рецепт B ниже | +| Нужен ИИ внутри (умный бот, генерация текста, ответы) | тот же Node-скелет + **`fetch` к `process.env.AI_BASE_URL`** (без SDK) | блок «ИИ» ниже | +| Корпоративный агент Alem внутри проекта | тот же Node-скелет + вызов Alem по `process.env.ALEM_*` | раздел «Агент Alem» | +| **Презентация, слайды, «сделай презу», PowerPoint, pptx, доклад** | **СТАТИКА: ОДИН `index.html` + pptxgenjs с CDN. Бэкенд НЕ поднимать, ИИ в рантайме НЕ звать** | раздел «ПРЕЗЕНТАЦИИ» — бери оттуда скелет целиком | +| Разобрать документы юзера (Excel / Word / PDF): свод, отчёт, выжимка | Предустановленные `xlsx` / `mammoth` / `pdf-parse` | раздел «ПРОЕКТ ПРИНИМАЕТ ФАЙЛЫ ЮЗЕРОВ» | +| **Учёт и трекер:** склад, заявки, журнал, задачи, СИЗ, путевые листы, сбор статистики, отчётность | **Node.js + `node:http` + `data.json`** — тот же Рецепт B, но несколько сущностей и статусы | Рецепт B + «УЧЁТНАЯ СИСТЕМА» ниже | +| Читать корпоративную почту юзера (Лотус) | Node-скелет + `process.env.LOTUS_*` | раздел «бот, читающий почту (Lotus)» | + +**Сначала таблица, потом код.** Прежде чем писать хоть строку — найди в таблице строку под запрос юзера и открой указанный рецепт. Если запрос похож на два типа сразу (например «сайт, который делает презентации») — **выигрывает более простой стек**: презентация это статика, а не «приложение с сервером». Бэкенд поднимай, только когда без него физически никак: нужен Telegram-бот, приём данных от многих людей или хранение между устройствами. + +**Язык бэкенда — ВСЕГДА Node.js в стиле CommonJS: `require(...)`, а не `import`.** Не смешивай ESM и CJS — это главная причина «node не запускается». Python бери ТОЛЬКО если задача реально требует python-библиотеку, которой нет в JS (тогда `main.py` + `requirements.txt`). По умолчанию — Node. + +**3 правила, которые ломаются чаще всего:** +1. Зависимости — ТОЛЬКО в `package.json` → `dependencies`. Их поставит `run`. НИКОГДА не пиши `npm install` в шелле. +2. Порт — всегда `process.env.PORT || 3000`. Не хардкодь другой. +3. Запуск бэкенда — ТОЛЬКО командой `run`. Никогда сам `node ...` / `npm start` в шелле. + +--- + +## 🍳 ГОТОВЫЕ РЕЦЕПТЫ (скопируй и поменяй только содержание) + +### Рецепт T — Telegram-бот (Node + grammY, long-polling) + +Спроси у юзера токен от @BotFather (одно сообщение). Создай 3 файла в текущей папке: + +**`package.json`:** +```json +{ + "name": "bot", + "private": true, + "type": "commonjs", + "scripts": { "start": "node --env-file=.env bot.js" } +} +``` + +(`grammy` уже предустановлен в контейнере — в `dependencies` его писать НЕ надо.) + +**`bot.js`:** +```js +const { Bot } = require("grammy"); +const bot = new Bot(process.env.BOT_TOKEN); + +bot.command("start", (ctx) => ctx.reply("Привет! Я бот. Напиши мне что-нибудь.")); +bot.on("message:text", (ctx) => ctx.reply("Ты написал: " + ctx.message.text)); + +bot.start(); +console.log("Бот запущен (long-polling)"); +``` + +**`.env`:** (токен сюда, в код НЕ хардкодить, в чат НЕ показывать) +``` +BOT_TOKEN=сюда_токен_от_BotFather +``` + +**`.gitignore`:** +``` +.env +node_modules/ +.vibe42-run.log +.vibe42-run.pid +``` + +Затем `run`. Дай юзеру имя бота, скажи «напиши боту в Telegram /start». Бот без порта — это нормально (long-polling). НЕ используй webhooks. + +**ВАЖНО:** `run` НЕ загружает `.env` сам — поэтому в `scripts.start` стоит `node --env-file=.env bot.js` (это грузит `.env` в окружение). Без `--env-file` бот упадёт с `Empty token!`. Если видишь `Empty token!` — проверь, что в `package.json` именно `node --env-file=.env bot.js` и что `.env` рядом с `bot.js`. + +**НИКОГДА не пиши `require("node-fetch")` / `import fetch from "node-fetch"`.** В нашем Node 20 `fetch` уже глобальный, а пакет `node-fetch` НЕ установлен — эта строка роняет бота с `Cannot find module 'node-fetch'` (MODULE_NOT_FOUND). Вызывай просто `fetch(...)` без импорта. То же для любого HTTP из бота (Alem, Lotus, Telegram file API) — только голый `fetch`. + +### Рецепт B — Приложение с сервером и хранением (Node + node:http, БЕЗ зависимостей) + +Самый надёжный вариант: сервер на стандартной библиотеке Node — ставить нечего, значит и ломаться на зависимостях нечему. Данные хранятся в `data.json`. Фронт — статичный `index.html`, который ходит в API через `fetch`. + +**`package.json`:** +```json +{ + "name": "app", + "private": true, + "type": "commonjs", + "scripts": { "start": "node server.js" } +} +``` + +**`server.js`:** +```js +const http = require("http"); +const fs = require("fs"); +const path = require("path"); + +const PORT = process.env.PORT || 3000; +const DATA = path.join(__dirname, "data.json"); + +function readItems() { + try { return JSON.parse(fs.readFileSync(DATA, "utf8")); } catch (_) { return []; } +} +function writeItems(items) { + fs.writeFileSync(DATA, JSON.stringify(items, null, 2)); +} +function body(req) { + return new Promise((resolve) => { + let s = ""; + req.on("data", (c) => (s += c)); + req.on("end", () => { try { resolve(JSON.parse(s || "{}")); } catch (_) { resolve({}); } }); + }); +} + +http.createServer(async (req, res) => { + // API: список записей + if (req.method === "GET" && req.url === "/api/items") { + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify(readItems())); + } + // API: добавить запись + if (req.method === "POST" && req.url === "/api/items") { + const item = await body(req); + const items = readItems(); + items.push({ id: Date.now(), ...item }); + writeItems(items); + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify({ ok: true })); + } + // Статика: index.html / style.css / script.js + let file = req.url === "/" ? "/index.html" : req.url.split("?")[0]; + const full = path.join(__dirname, file); + if (full.startsWith(__dirname) && fs.existsSync(full) && fs.statSync(full).isFile()) { + const ext = path.extname(full); + const type = ext === ".css" ? "text/css" : ext === ".js" ? "application/javascript" : "text/html"; + res.writeHead(200, { "Content-Type": type + "; charset=utf-8" }); + return res.end(fs.readFileSync(full)); + } + res.writeHead(404); res.end("Not found"); +}).listen(PORT, () => console.log("Сервер на порту " + PORT)); +``` + +**`index.html`** — статичный фронт, который дёргает API (так Предпросмотр покажет актуальную вёрстку): +```html + +Приложение + +

Записи

+
+ + + +``` + +`.gitignore`: `data.json`, `node_modules/`, `.vibe42-run.log`, `.vibe42-run.pid`. Затем `run` → дай юзеру ссылку из вывода ЦЕЛИКОМ (с `?t=...`). + +### Блок «ИИ» — умный ответ БЕЗ ключей и БЕЗ SDK + +Нужен ИИ в боте/приложении? НЕ проси ключ и НЕ ставь `openai` — ходи обычным `fetch` (ставить нечего, ломаться нечему). `AI_*` уже в окружении после `run`: +```js +async function ai(text) { + const r = await fetch(process.env.AI_BASE_URL + "/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer " + process.env.AI_API_KEY }, + body: JSON.stringify({ model: process.env.AI_MODEL, messages: [{ role: "user", content: text }] }) + }); + if (!r.ok) throw new Error("ИИ недоступен, код " + r.status); + const j = await r.json(); + const out = j.choices?.[0]?.message?.content; + if (!out) throw new Error("ИИ вернул пустой ответ"); + return out; +} +``` +В боте: `bot.on("message:text", async (ctx) => ctx.reply(await ai(ctx.message.text)));`. Ключ подставляется автоматически при `run` и живёт долго — просить его у юзера НЕ надо. + +**⚠️ ЖЕЛЕЗНОЕ ПРАВИЛО: сбой ИИ показывай честно.** Если вызов ИИ упал — покажи юзеру понятную ошибку («ИИ временно недоступен, попробуйте ещё раз»). **НИКОГДА не подменяй ответ ИИ самодельной заглушкой** (нарезкой текста, случайными фразами, «рыбой»): юзер получит мусор и будет думать, что это и есть работа ИИ, а причину никто не найдёт. Лучше явная ошибка, чем тихая подделка. + +--- + +## 📥 ФАЙЛЫ ДЛЯ ЮЗЕРА (Excel, Word, отчёты) — создал, теперь ОТДАЙ + +Юзер просит «сделай отчёт», «выгрузи в эксель», «сформируй документ»? Файл мало создать — юзер должен его забрать. + +**Нужен Excel — бери команду `makexlsx`.** Она в твоём шелле, как `readdoc`, и делает настоящую книгу `.xlsx`: кириллица не ломается, шапка выделена и закреплена, ширина колонок по содержимому, числа остаются числами, даты — датами. + +```bash +# из CSV, который ты сам собрал +makexlsx Отчёт_июль.xlsx --from data.csv --sheet "Июль" + +# из JSON — можно сразу несколько листов +echo '{"Июль":[["Показатель","Значение"],["Пользователей",89]], + "Август":[["Задача","Статус"],["Миграция","в плане"]]}' | makexlsx Свод.xlsx +``` + +Принимает три вида JSON: строки `[[...],[...]]`, массив объектов `[{"Дата":"07.07"}, ...]` (ключи становятся шапкой) и словарь листов `{"Имя листа": [[...]]}`. + +**Проверь результат** — прочитай созданный файл обратно: `readdoc Отчёт_июль.xlsx`. + +**Правило имени: НЕ подделывай формат.** +- **НИКОГДА не клей двойное расширение** (`Otchet.xlsx.csv`, `report.docx.txt`). Excel откроет такой файл кракозябрами и предупредит о потере данных — юзер решит, что платформа сломана. +- Не используешь `makexlsx` — тогда честный `.csv` и **так и назови**, с BOM (`\uFEFF` в начале), иначе кириллица поедет. +- Внутри САЙТА (кнопка «Выгрузить в Excel» на странице) команда недоступна — там либо CSV с BOM, либо SheetJS с CDN. + +**Правило выдачи: ВСЕГДА скажи, где скачать.** Создал файл — следующей же фразой напиши юзеру: + +> Готово, файл `Otchet.xlsx` лежит в проекте. Открой вкладку **Файлы** справа и нажми **«Скачать»** напротив него. + +Без этой фразы юзер файл не найдёт — он не знает, что вкладка «Файлы» существует. + +**Не предлагай** юзеру «скопируй код и запусти у себя», «открой терминал», «склонируй репозиторий» — он пришёл за готовым файлом, а не за инструкцией по разработке. + +--- + +## 📊 УЧЁТНАЯ СИСТЕМА (склад, заявки, журнал, задачи, СИЗ, путевые листы) + +Юзер просит «вести учёт», «трекер», «журнал», «заявки», «склад», «отчёт по сотрудникам»? Это Рецепт B, но с несколькими сущностями и статусами. Не изобретай схему — бери эту. + +**Модель данных — один файл `data.json`, внутри списки:** +```js +// { "items": [...], "log": [...] } // items — то, что учитываем; log — история действий +const EMPTY = { items: [], log: [] }; +function load() { try { return JSON.parse(fs.readFileSync(DATA, "utf8")); } catch { return EMPTY; } } +function save(d) { fs.writeFileSync(DATA, JSON.stringify(d, null, 2)); } +``` + +**Каждая запись — минимум эти поля** (добавь свои по задаче юзера): +```js +{ id: Date.now(), title: "Каска защитная", qty: 12, person: "Иванов И.", + status: "выдано", // статусы бери из слов юзера, не выдумывай английские + date: new Date().toISOString().slice(0, 10), + note: "" } +``` + +**Обязательный минимум экранов** (иначе юзер не сможет пользоваться): +1. **Таблица** всех записей + строка поиска (фильтр по подстроке на клиенте). +2. **Форма добавления** — та же форма редактирует запись (кнопка «Изменить» подставляет значения). +3. **Фильтр по статусу** — кнопки-чипсы над таблицей. +4. **Счётчики сверху**: всего / по каждому статусу. +5. **Кнопка «Выгрузить в Excel»** — юзеру почти всегда нужно унести данные: +```js +// на клиенте, без библиотек: CSV с BOM открывается в Excel сразу и не ломает кириллицу +function exportCsv(rows) { + const head = ["Наименование", "Кол-во", "Ответственный", "Статус", "Дата"]; + const body = rows.map(r => [r.title, r.qty, r.person, r.status, r.date] + .map(v => '"' + String(v ?? "").replace(/"/g, '""') + '"').join(";")); + const csv = "\uFEFF" + [head.join(";"), ...body].join("\n"); + const a = document.createElement("a"); + a.href = URL.createObjectURL(new Blob([csv], { type: "text/csv" })); + a.download = "vygruzka.csv"; a.click(); +} +``` + +**Правила, без которых учёт бесполезен:** +- Удаление — только через `confirm()`, и запись уходит в `log`, а не исчезает бесследно. +- Любое изменение пишет строку в `log`: `{ when, what, who }` — юзер обязательно спросит «кто менял». +- Даты показывай как `ДД.ММ.ГГГГ`, хранить можно как есть. +- Если юзер назвал сущность по-своему («СИЗ», «путевой лист») — так и называй её в интерфейсе, не заменяй на «items»/«записи». + +--- + +## 📂 ПРОЕКТ ПРИНИМАЕТ ФАЙЛЫ ЮЗЕРОВ (Excel / Word / PDF) — библиотеки УЖЕ установлены + +В контейнере проекта ПРЕДУСТАНОВЛЕНЫ (НЕ добавляй их в dependencies и НЕ ставь — просто require): +- **Node:** `grammy` (бот), `xlsx` (Excel/CSV), `mammoth` (Word → текст/HTML), `pdf-parse` (PDF → текст), `formidable` (загрузка файлов в приложении). +- **Python:** `openpyxl`, `python-docx`, `pypdf`, `requests`. + +Всё, чего в списке нет, — добавляй в `dependencies`, поставит `run`. + +**Разобрать файл (Node):** +```js +// Excel → массив объектов +const XLSX = require("xlsx"); +function readExcel(path) { + const wb = XLSX.readFile(path); + return XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]]); +} +// Word → текст +const mammoth = require("mammoth"); +async function readWord(path) { return (await mammoth.extractRawText({ path })).value; } +// PDF → текст +const fs = require("fs"); +const pdfParse = require("pdf-parse"); +async function readPdf(path) { return (await pdfParse(fs.readFileSync(path))).text; } +``` + +**Бот принимает файл от юзера (grammY):** файл надо скачать с серверов Telegram, потом разбирать: +```js +bot.on("message:document", async (ctx) => { + const file = await ctx.getFile(); + const url = "https://api.telegram.org/file/bot" + process.env.BOT_TOKEN + "/" + file.file_path; + const buf = Buffer.from(await (await fetch(url)).arrayBuffer()); + const name = ctx.message.document.file_name || "file"; + if (name.endsWith(".xlsx") || name.endsWith(".xls")) { + const wb = require("xlsx").read(buf); + const rows = require("xlsx").utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]]); + return ctx.reply("Строк в таблице: " + rows.length); + } + if (name.endsWith(".docx")) { + const t = (await require("mammoth").extractRawText({ buffer: buf })).value; + return ctx.reply("Текст из Word (начало): " + t.slice(0, 300)); + } + if (name.endsWith(".pdf")) { + const t = (await require("pdf-parse")(buf)).text; + return ctx.reply("Текст из PDF (начало): " + t.slice(0, 300)); + } + ctx.reply("Пока умею: .xlsx, .docx, .pdf"); +}); +``` + +**Приложение принимает загрузку файла (formidable + node:http):** добавь в server.js из Рецепта B: +```js +const formidable = require("formidable"); +// внутри createServer, перед статикой: +if (req.method === "POST" && req.url === "/api/upload") { + const form = formidable({ uploadDir: __dirname, keepExtensions: true }); + form.parse(req, (err, fields, files) => { + const f = files.file && files.file[0]; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: !err && !!f, path: f ? f.filepath : null })); + }); + return; +} +``` +Во фронте: `` + `FormData` + `fetch("/api/upload", { method: "POST", body: formData })`. Дальше разбирай файл рецептами выше и отвечай юзеру данными. + +--- + +## 🚑 НЕ ЗАПУСКАЕТСЯ? ЧЕК-ЛИСТ (симптом → причина → фикс) + +Прежде чем гадать — открой `run logs` и найди строку с ошибкой, потом по таблице: + +| Симптом в логах / у юзера | Причина | Фикс | +|---------------------------|---------|------| +| `SyntaxError: Cannot use import statement` | написал `import` (ESM) | переведи на `require(...)` (CommonJS). У нас по умолчанию CommonJS. | +| `run` пишет «не нашёл, что запускать» | нет `package.json` со `scripts.start` и нет `bot.js`/`server.js` | добавь `package.json` с `"scripts": {"start": "node bot.js"}` (или `server.js`) | +| `Error: Cannot find module 'X'` | зависимости `X` нет в `package.json` | добавь `X` в `dependencies`, запусти `run` заново (он поставит). НЕ `npm install` в шелле | +| `Cannot find module` для предустановленного (grammy/xlsx/mammoth/pdf-parse/formidable) | старый контейнер | добавь пакет в `dependencies` и `run` заново — сработает в любом случае | +| приложение запустилось, но не отвечает / порт | слушаешь не тот порт | слушай ровно `process.env.PORT || 3000` | +| `Empty token!` / бот не видит токен | `.env` не загружается | в `package.json` → `"start": "node --env-file=.env bot.js"`; `.env` рядом с `bot.js` | +| бот не отвечает в Telegram | используешь webhook | только `bot.start()` (long-polling), НЕ webhooks; токен в `.env` → `process.env.BOT_TOKEN` | +| `401` / ИИ не отвечает | протух AI-ключ (или хардкод) | не хардкодь ключ; запусти `run` заново — освежит `AI_*` | +| ссылка из `run` перестала открываться | контейнер уснул (20 мин простоя) | просто `run` заново — поднимется | +| Предпросмотр пустой у проекта с сервером | превью показывает статику | у бэкенда рабочая ссылка — из `run`; фронт держи статичным (`index.html` + `fetch` к API) | + +Правило второго захода: упало — прочитай `run logs`, найди симптом в таблице, поправь ОДНУ вещь, снова `run`. Не переписывай всё с нуля. + +--- + +## 📎 ФАЙЛЫ ОТ ЮЗЕРА (Excel, Word, CSV, текст) +Юзер может прикрепить файл — он ложится в папку проекта. **Читать Excel/Word напрямую (`cat`) нельзя** — они бинарные (zip+xml). Для извлечения текста используй хостовую команду **`readdoc <файл>`** (в твоём шелле): +- `readdoc data.xlsx` — все листы Excel как таблицы (значения через табуляцию); +- `readdoc doc.docx` — текст Word по абзацам; +- `readdoc doc.pdf` — текст из PDF (сканы без текстового слоя не прочтёт); +- `readdoc table.csv` / `.tsv` / `.txt` — как есть (можно и `cat`); +- большой файл — ограничь: `readdoc big.xlsx --max 100`. +Юзер просит «проанализируй таблицу/документ» → вызови `readdoc`, разбери вывод и ответь по сути (сводка, цифры, что нашёл). Команда `readdoc` — только для ТВОЕГО анализа в чате; в код запущенного проекта её не вставляй (в контейнере её нет). + +**Не нашёл файл в текущей папке?** Загруженный файл мог лечь в КОРЕНЬ воркспейса (на уровень выше cwd). Проверь `ls ..` и читай/копируй оттуда: `readdoc "../имя файла.xlsx"` или `cp "../имя.xlsx" .`. Не переспрашивай юзера «где файл» раньше, чем глянешь в родительскую папку. + +**Используй ВСЕ данные, не «первые N».** Если строишь из файла таблицу/список/каталог — выведи ВСЕ записи, а не 5-6. **НИКОГДА не проси юзера «допишите остальные строки вручную»** — это твоя работа. Данных много (десятки+ строк)? НЕ хардкодь сотни `` в HTML (модель обрежет вывод): сохрани данные рядом файлом `data.json` (массив объектов) и отрисуй в `index.html` через JS — `fetch('data.json').then(r=>r.json()).then(rows => …)` + `map`/`innerHTML`. Так отрисуются все строки, и появится место для поиска/фильтра. `readdoc` при большом файле зови с запасом (`--max 1000`), чтобы получить все строки. + +--- + +## 🗺 СЦЕНАРИЙ ПЕРВОГО ЗАХОДА (юзер только зашёл, ещё ничего нет) + +1. Поздоровайся коротко: «Привет! Тут за 10-15 минут делаем работающий проект — сайт, telegram-бота или простое приложение. Что хочешь сделать?» +2. Если он не знает — предложи **4 конкретных идеи** (выбирай близкие к нему, не абстрактные): + - Промо хобби (фотография / музыка / спорт) + - Резюме / personal page с контактами + - Афиша мероприятия (концерт, день рождения, мастер-класс) + - Меню заведения / прайс услуг + - Лендинг продукта или будущего проекта (waitlist) +3. Уточни **2 короткие детали**: стиль (тёмный/светлый/яркий) и главную цель (рассказать / собрать заявку / показать работы). +4. Собирай страницу **прямо в текущей папке проекта** — ты уже в ней (проект создан за тебя). Создавай `index.html`/`style.css`/`script.js` тут же. **НЕ запускай `./new-project`** и не делай `cd` в другие папки. Не спрашивай разрешения на каждый шаг. + +--- + +## 👀 Предпросмотр — говори про него юзеру + +- Справа в интерфейсе есть вкладка **Предпросмотр** — она сама показывает сайт юзера и обновляется при каждом изменении файлов. Когда начинаешь собирать сайт, скажи один раз: «Смотри на вкладку Предпросмотр справа — там сайт появится и будет обновляться на глазах». +- Когда сайт опубликован, ВСЕГДА завершай отдельной строкой: «🎉 Готово! Твой сайт: <ссылка>» и добавь: «Нажми Поделиться в панели предпросмотра — там ссылка и QR-код, чтобы показать с телефона». + +--- + +## 🧠 Память о юзере — веди её сам + +Файл памяти: `/srv/opencode/workspaces/users/Timur/.mimo/memory.md` (dot-папка `.mimo` не видна в Предпросмотре — так и задумано). Папка `.mimo/` уже создана, тебе нужно только читать/писать `memory.md`. + +**В НАЧАЛЕ каждой сессии** — прочитай `/srv/opencode/workspaces/users/Timur/.mimo/memory.md` (если есть) ПЕРЕД первым ответом. Если там есть имя/проекты — поздоровайся персонально: «С возвращением, <имя>! Продолжим <последний проект> или сделаем новый?». Если файла нет — это новый юзер, работай по обычному сценарию. + +**ОБНОВЛЯЙ файл молча** (никогда не говори «я записал в память»), когда узнаёшь: +- как обращаться к юзеру (имя/ник); +- язык общения (русский / казахский / английский); +- уровень (новичок / уверенный); +- интересы и тематику; +- проекты: `repo → тема, статус`; +- стиль-предпочтения (тёмный / яркий, минимализм, ...); +- незакрытые хвосты («хотел добавить фото», «вернёмся к форме»). + +**Формат:** короткий markdown-список, максимум ~30 строк. Старое неактуальное — удаляй, не копи. + +**ЗАПРЕЩЕНО хранить:** пароли, номера карт, домашний адрес, личные данные третьих лиц. + +**Лотус ≠ владелец аккаунта.** Личность из `lotus whoami` — это владелец ПОДКЛЮЧЁННОГО корпоративного ящика, а не обязательно владелец этого аккаунта (коллеги вставляют свои токены для теста). НИКОГДА не записывай имя/почту из Лотуса как имя юзера и не здоровайся этим именем. Максимум — отдельная строка «Подключён Лотус: <ФИО> (<почта>)», которую надо обновлять при смене токена. Обращайся к юзеру только по имени, которым он сам представился в чате. + +**Говори языком юзера.** Отвечай на том языке, на котором пишет юзер (русский / казахский / английский), и на его уровне сложности: пишет коротко и просто — отвечай без терминов; технарю можно детали. Юзер переключил язык — переключись следом. + +**Это касается ВСЕГО видимого текста, а не только финального ответа.** Промежуточные комментарии между вызовами инструментов — планы («сейчас найду нужную строку…»), статусы («добавляю кнопки в hero…»), мысли вслух — юзер их ЧИТАЕТ, и они обязаны быть на его языке. Писать «We'll edit line 139» русскоязычному юзеру — ошибка. По-английски остаются только код, команды и имена файлов. + +--- + +## 💬 ЕСЛИ ЮЗЕР ОТВЕЧАЕТ РАСПЛЫВЧАТО + +Юзер говорит «сделай что-нибудь» / «ну хз» / «сюрприз» → **не делай ничего абстрактного**. + +Скажи: «Давай определимся, я задам 3 коротких вопроса: +1. Это для тебя лично, для проекта/бизнеса, или для события? +2. Главная цель — рассказать о чём-то / собрать заявку / показать портфолио? +3. Любимое настроение — строгое тёмное, лёгкое светлое, яркое цветное?» + +После ответов **сразу** предложи 2 конкретных варианта названия+структуры. Дай выбрать и иди делать. + +--- + +## 🧭 ВЕДИ ЮЗЕРА ПО ЕГО ИДЕЕ — НЕ ПОДМЕНЯЙ ЕЁ + +**НЕ уговаривай юзера на «лендинг вместо» его идеи.** Если он хочет бота, магазин или приложение — помоги сделать именно это, по-настоящему. Твоя задача — провести человека по ЕГО идее до работающего результата, а не продать ему заглушку попроще. + +Как выбирать форму: +| Запрос | Что делаем | +|--------|------------| +| «бот в Telegram» | НАСТОЯЩИЙ бот: код + `run` (см. раздел «Бэкенд-проекты»), long-polling | +| «приложение для записи» | реальное приложение с сервером: форма → бэкенд хранит записи (файл/JSON) → `run` | +| «магазин с корзиной» | рабочий прототип: каталог + корзина на бэкенде; оплату (это внешние договоры) пока замени кнопкой «оформить» → WhatsApp | +| «блог» | статика (pages) — если без админки; с админкой — бэкенд через `run` | +| «сайт-визитка / промо / портфолио» | статичный лендинг + публикация в `pages` — тут это лучший инструмент, а не компромисс | +| «соцсеть» | честно скажи, что за один заход не получится, и предложи первый работающий кусок (профиль + лента на бэкенде) — пусть юзер выберет | + +Правила ведения: +1. Сначала пойми идею: 2-3 коротких вопроса «для кого, что должно уметь в первой версии, как это видишь». +2. Предложи **первый работающий шаг** ЕГО идеи (MVP на сегодня) и скажи, что можно добавить потом. Не ужимай идею молча. +3. Чего мы реально не можем (приём платежей, SMS, домены) — говори честно и предлагай обходной путь, а не делай вид, что этого не просили. +4. **Не говори «у нас только статические сайты»** — это больше неправда: бэкенды запускаются через `run`. + +--- + +## 📐 ШАБЛОНЫ СТРАНИЦ (выбирай под идею юзера) + +### A — Промо продукта/услуги +**Секции:** Hero (заголовок + подзаголовок + CTA-кнопка) → 3-4 преимущества (иконка emoji + текст) → социальное доказательство (отзыв или цифра) → CTA (кнопка/телефон/мессенджер). + +### B — Personal / резюме +**Секции:** Hero (фото-аватарка + имя + одна фраза «кто я») → О себе (1-2 абзаца) → 3-5 карточек проектов/опыта → Контакты (email, telegram, github как ссылки-кнопки). + +### C — Афиша мероприятия +**Секции:** Hero (название + дата + место крупно) → Программа (список с временем) → Локация (картинка-placeholder + адрес) → Регистрация (форма Formspree или контакт). + +### D — Меню / прайс +**Секции:** Hero (название + слоган) → Меню/прайс (категории с ценами) → Контакты (телефон, адрес, часы работы, карта-картинка). + +### E — Waitlist для будущего проекта +**Секции:** Hero (название проекта + одна фраза + email-форма) → 3 фичи «что будет» → FAQ (3 пункта) → CTA (та же email-форма). + +Все шаблоны — **одна страница, прокрутка вниз**. Никаких роутов, ничего динамического. + +--- + +## 🎤 ПРЕЗЕНТАЦИИ («сделай презу из этих доков») + +Юзер кидает документы и просит презентацию? Флоу: +1. Прочитай доки командой `readdoc` (умеет .xlsx, .docx, .pdf, .csv/.txt). Файлы могут лежать в корне воркспейса — проверь `ls ..`. +2. Выдели суть: 5-9 слайдов, на слайде заголовок + 3-5 коротких пунктов. Не переноси абзацы целиком — сжимай до тезисов. +3. Возьми скелет ниже (это ОДИН файл `index.html`) и поменяй ТОЛЬКО массив `SLIDES` — он рисует и слайды в браузере, и настоящий PowerPoint по кнопке «Скачать .pptx». Остальной код не трогай. +4. Покажи через Предпросмотр (стрелки/клавиши листают). Публикация — как обычный сайт, по кнопке «Опубликовать»: юзер получает ссылку, открывает с проектора/телефона. +5. Скажи юзеру: «Кнопка Скачать .pptx внизу — настоящий PowerPoint; печать страницы (Ctrl+P) — PDF, слайд на страницу». + +Скелет (файл `index.html`, целиком): +```html + + + + + +Презентация + + + + +
+ +
+ + + +``` + +Правила: цвета/шрифты можно подгонять под тему юзера в ` + """, unsafe_allow_html=True) + + st.markdown("

🚛 BI-дашборд Автобазы

", unsafe_allow_html=True) + st.markdown("

Система мониторинга эффективности транспортных подразделений

", unsafe_allow_html=True) + + with st.form("login_form"): + username = st.text_input("Логин", placeholder="admin") + password = st.text_input("Пароль", type="password", placeholder="••••••••") + submitted = st.form_submit_button("Войти", use_container_width=True) + + if submitted: + if not username or not password: + st.error("Введите логин и пароль") + else: + user = check_credentials(username, password) + if user: + st.session_state.authenticated = True + st.session_state.user_role = user['role'] + st.session_state.username = user['name'] + st.session_state.permissions = user['permissions'] + st.rerun() + else: + st.error("Неверный логин или пароль") + + # Демонстрационные учётные данные + with st.expander("📋 Тестовые учётные данные"): + st.markdown(""" + | Логин | Пароль | Роль | Права | + |-------|--------|------|-------| + | admin | admin123 | Администратор | Полный доступ | + | manager | manager123 | Руководитель | Просмотр + экспорт | + | accountant | accountant123 | Бухгалтер | Финансы + экспорт | + | driver | driver123 | Водитель | Только свои данные | + """) + +# ============================================================================ +# БИЗНЕС-ЛОГИКА: РАСЧЁТ KPI +# ============================================================================ + +class KPICalculator: + """Калькулятор ключевых показателей эффективности""" + + def __init__(self, data_fetcher: DataFetcher): + self.fetcher = data_fetcher + self._cache = {} + + def load_all_data(self): + """Загрузить все данные в кэш""" + self._cache = { + 'fleet': self.fetcher.get_fleet_data(), + 'requests': self.fetcher.get_requests_data(), + 'accidents': self.fetcher.get_accidents_data(), + 'maintenance': self.fetcher.get_maintenance_data(), + 'fuel': self.fetcher.get_fuel_data(), + 'employees': self.fetcher.get_employees_data() + } + return self._cache + + def calc_request_fulfillment_rate(self, department: Optional[str] = None) -> float: + """ + Коэффициент исполнения заявок. + Formula: Исполнено / Всего заявок * 100% + """ + df = self._cache['requests'] + if department: + df = df[df['department'] == department] + + if len(df) == 0: + return 0.0 + + return round(df['fulfilled'].mean() * 100, 2) + + def calc_fleet_utilization(self, department: Optional[str] = None) -> float: + """ + Коэффициент использования парка (КИП). + Formula: (Всего ТС - В ремонте) / Всего ТС * 100% + """ + df = self._cache['fleet'] + if department: + df = df[df['department'] == department] + + if len(df) == 0: + return 0.0 + + active = len(df[df['status'] != 'В ремонте']) + total = len(df) + + return round((active / total) * 100, 2) + + def calc_safety_score(self, department: Optional[str] = None) -> float: + """ + Показатель безопасности. + Formula: 100 - (ДТП * 5 + Нарушения * 2) / Всего ТС * 10 + """ + df_fleet = self._cache['fleet'] + if department: + df_fleet = df_fleet[df_fleet['department'] == department] + + df_accidents = self._cache['accidents'] + + if department: + # Нужно связать ТС с подразделением + vehicle_ids = df_fleet['vehicle_id'].tolist() + df_accidents = df_accidents[df_accidents['vehicle_id'].isin(vehicle_ids)] + + total_vehicles = len(df_fleet) + if total_vehicles == 0: + return 100.0 + + accidents_count = len(df_accidents[df_accidents['type'] == 'ДТП']) + violations_count = len(df_accidents[df_accidents['type'] == 'Нарушение ПДД']) + + score = 100 - ((accidents_count * 5 + violations_count * 2) / total_vehicles * 10) + return round(max(0, min(100, score)), 2) + + def calc_maintenance_score(self, department: Optional[str] = None) -> float: + """ + Показатель эксплуатации и ремонтов. + Formula: 100 - (Простой дней / Всего ТС) * 2 + """ + df_fleet = self._cache['fleet'] + if department: + df_fleet = df_fleet[df_fleet['department'] == department] + + df_maint = self._cache['maintenance'] + + if department: + vehicle_ids = df_fleet['vehicle_id'].tolist() + df_maint = df_maint[df_maint['vehicle_id'].isin(vehicle_ids)] + + total_vehicles = len(df_fleet) + if total_vehicles == 0: + return 100.0 + + total_downtime = df_maint['downtime_days'].sum() + + score = 100 - (total_downtime / total_vehicles * 2) + return round(max(0, min(100, score)), 2) + + def calc_fuel_efficiency(self, department: Optional[str] = None) -> Dict: + """ + Показатели финансов и ГСМ. + Returns: dict с перерасходом и стоимостью 1 км + """ + df_fleet = self._cache['fleet'] + if department: + df_fleet = df_fleet[df_fleet['department'] == department] + + df_fuel = self._cache['fuel'] + + if department: + vehicle_ids = df_fleet['vehicle_id'].tolist() + df_fuel = df_fuel[df_fuel['vehicle_id'].isin(vehicle_ids)] + + if len(df_fuel) == 0: + return {'overrun_percent': 0.0, 'cost_per_km': 0.0} + + planned = df_fuel['planned_cost'].sum() + actual = df_fuel['actual_cost'].sum() + total_mileage = df_fleet['mileage'].sum() + + overrun = ((actual - planned) / planned * 100) if planned > 0 else 0 + cost_per_km = (actual / total_mileage * 1000) if total_mileage > 0 else 0 + + return { + 'overrun_percent': round(overrun, 2), + 'cost_per_km': round(cost_per_km, 2) + } + + def calc_department_rating(self) -> pd.DataFrame: + """ + Рассчитать сводный рейтинг подразделений. + Weighted score: + - Исполнение заявок: 25% + - КИП: 20% + - Безопасность: 20% + - Эксплуатация: 15% + - Финансы: 20% + """ + departments = self._cache['fleet']['department'].unique() + + ratings = [] + for dept in departments: + kpi1 = self.calc_request_fulfillment_rate(dept) + kpi2 = self.calc_fleet_utilization(dept) + kpi3 = self.calc_safety_score(dept) + kpi4 = self.calc_maintenance_score(dept) + kpi5 = self.calc_fuel_efficiency(dept) + + # Нормализуем KPI5 (перерасход) в score (0-100) + fuel_score = max(0, 100 - abs(kpi5['overrun_percent']) * 2) + + # Взвешенная оценка + weighted_score = ( + kpi1 * 0.25 + + kpi2 * 0.20 + + kpi3 * 0.20 + + kpi4 * 0.15 + + fuel_score * 0.20 + ) + + ratings.append({ + 'Подразделение': dept, + 'Исполнение заявок': kpi1, + 'КИП': kpi2, + 'Безопасность': kpi3, + 'Эксплуатация': kpi4, + 'ГСМ перерасход %': kpi5['overrun_percent'], + 'Стоимость 1 км': kpi5['cost_per_km'], + 'Сводный рейтинг': round(weighted_score, 2) + }) + + df_rating = pd.DataFrame(ratings) + df_rating = df_rating.sort_values('Сводный рейтинг', ascending=False) + df_rating['Место'] = range(1, len(df_rating) + 1) + + return df_rating + + def get_budget_report(self, year: int = 2027) -> Dict: + """ + Сформировать отчёт для обоснования бюджета. + """ + df_fleet = self._cache['fleet'] + df_maint = self._cache['maintenance'] + df_fuel = self._cache['fuel'] + df_requests = self._cache['requests'] + + # Текущие расходы + total_maintenance = df_maint['cost'].sum() + total_fuel = df_fuel['actual_cost'].sum() + total_fines = self._cache['accidents']['fine_amount'].sum() + + # Прогноз на 2027 + vehicles_count = len(df_fleet) + avg_age = datetime.now().year - df_fleet['year'].median() + + # Коэффициенты индексации + inflation = 1.12 # 12% инфляция + wear_factor = 1 + (avg_age * 0.05) # Износ + + forecast = { + 'Текущие расходы ТО': total_maintenance, + 'Текущие расходы ГСМ': total_fuel, + 'Штрафы и инциденты': total_fines, + 'Всего за 2024': total_maintenance + total_fuel + total_fines, + 'Прогноз ТО 2027': int(total_maintenance * inflation * wear_factor), + 'Прогноз ГСМ 2027': int(total_fuel * inflation), + 'Рекомендуемый бюджет': int((total_maintenance + total_fuel) * inflation * wear_factor), + 'Автопарк': vehicles_count, + 'Средний возраст': round(avg_age, 1), + 'Потребность в обновлении': int(vehicles_count * 0.15) # 15% парка + } + + return forecast + +# ============================================================================ +# ВИЗУАЛИЗАЦИЯ +# ============================================================================ + +def render_rating_table(rating_df: pd.DataFrame): + """Таблица рейтинга с цветовым кодированием""" + def color_score(val): + if val >= 80: + color = '#d4edda' # Зелёный + elif val >= 60: + color = '#fff3cd' # Жёлтый + else: + color = '#f8d7da' # Красный + return f'background-color: {color}' + + styled = rating_df.style.applymap( + color_score, + subset=['Исполнение заявок', 'КИП', 'Безопасность', 'Эксплуатация', 'Сводный рейтинг'] + ) + + st.dataframe(styled, use_container_width=True, hide_index=True) + +def render_kpi_cards(calculator: KPICalculator): + """Карточки общих KPI""" + cols = st.columns(5) + + kpi1 = calculator.calc_request_fulfillment_rate() + kpi2 = calculator.calc_fleet_utilization() + kpi3 = calculator.calc_safety_score() + kpi4 = calculator.calc_maintenance_score() + kpi5 = calculator.calc_fuel_efficiency() + + with cols[0]: + st.metric( + label="📋 Исполнение заявок", + value=f"{kpi1}%", + delta=f"{kpi1 - 85:.1f}%", + delta_color="normal" + ) + + with cols[1]: + st.metric( + label="🚛 КИП", + value=f"{kpi2}%", + delta=f"{kpi2 - 80:.1f}%", + delta_color="normal" + ) + + with cols[2]: + st.metric( + label="🛡 Безопасность", + value=f"{kpi3}", + delta=f"{kpi3 - 75:.1f}", + delta_color="normal" + ) + + with cols[3]: + st.metric( + label="🔧 Эксплуатация", + value=f"{kpi4}", + delta=f"{kpi4 - 80:.1f}", + delta_color="normal" + ) + + with cols[4]: + overrun = kpi5['overrun_percent'] + st.metric( + label="⛽ Перерасход ГСМ", + value=f"{overrun}%", + delta=f"{overrun:.1f}%", + delta_color="inverse" if overrun > 0 else "normal" + ) + +def render_charts(calculator: KPICalculator): + """Графики и диаграммы""" + rating_df = calculator.calc_department_rating() + + col1, col2 = st.columns(2) + + with col1: + # Рейтинг подразделений + fig_bar = px.bar( + rating_df.sort_values('Сводный рейтинг'), + x='Сводный рейтинг', + y='Подразделение', + orientation='h', + title='📊 Сводный рейтинг подразделений', + color='Сводный рейтинг', + color_continuous_scale='RdYlGn' + ) + fig_bar.update_layout(height=400) + st.plotly_chart(fig_bar, use_container_width=True) + + with col2: + # Радарная диаграмма KPI + departments = rating_df['Подразделение'].tolist() + + fig_radar = go.Figure() + + for dept in departments: + dept_data = rating_df[rating_df['Подразделение'] == dept].iloc[0] + + fig_radar.add_trace(go.Scatterpolar( + r=[ + dept_data['Исполнение заявок'], + dept_data['КИП'], + dept_data['Безопасность'], + dept_data['Эксплуатация'], + max(0, 100 - abs(dept_data['ГСМ перерасход %']) * 2) + ], + theta=['Исполнение', 'КИП', 'Безопасность', 'Эксплуатация', 'ГСМ'], + fill='toself', + name=dept + )) + + fig_radar.update_layout( + polar=dict(radialaxis=dict(visible=True, range=[0, 100])), + showlegend=True, + title='🎯 Сравнение KPI подразделений', + height=400 + ) + + st.plotly_chart(fig_radar, use_container_width=True) + + # Динамика по месяцам + col3, col4 = st.columns(2) + + with col3: + # Структура расходов + df_fuel = calculator._cache['fuel'] + monthly_fuel = df_fuel.groupby(df_fuel['date'].str[:7])['actual_cost'].sum() + + fig_line = px.line( + x=monthly_fuel.index, + y=monthly_fuel.values, + title='💰 Динамика расходов на ГСМ по месяцам', + markers=True + ) + fig_line.update_layout(height=350) + st.plotly_chart(fig_line, use_container_width=True) + + with col4: + # Распределение типов ТС + df_fleet = calculator._cache['fleet'] + vehicle_types = df_fleet['vehicle_type'].value_counts() + + fig_pie = px.pie( + values=vehicle_types.values, + names=vehicle_types.index, + title='🚚 Структура автопарка', + hole=0.4 + ) + fig_pie.update_layout(height=350) + st.plotly_chart(fig_pie, use_container_width=True) + +def render_budget_report(calculator: KPICalculator): + """Отчёт для обоснования бюджета 2027""" + st.header("📈 Отчёт для обоснования бюджета на 2027 год") + + forecast = calculator.get_budget_report(2027) + + # Карточки с цифрами + col1, col2, col3, col4 = st.columns(4) + + with col1: + st.metric( + label="Всего за 2024", + value=f"{forecast['Всего за 2024']:,.0f} ₽" + ) + + with col2: + st.metric( + label="Рекомендуемый бюджет 2027", + value=f"{forecast['Рекомендуемый бюджет']:,.0f} ₽" + ) + + with col3: + st.metric( + label="Автопарк", + value=f"{forecast['Автопарк']} ТС" + ) + + with col4: + st.metric( + label="Обновление парка", + value=f"{forecast['Потребность в обновлении']} ТС" + ) + + # Детализация + st.subheader("Структура расходов") + + budget_data = pd.DataFrame({ + 'Статья расходов': [ + 'ТО и ремонты (прогноз)', + 'ГСМ (прогноз)', + 'Резерв на инциденты', + 'Обновление парка' + ], + 'Сумма (₽)': [ + forecast['Прогноз ТО 2027'], + forecast['Прогноз ГСМ 2027'], + int(forecast['Штрафы и инциденты'] * 1.12), + forecast['Потребность в обновлении'] * 2500000 + ] + }) + + st.dataframe(budget_data, use_container_width=True, hide_index=True) + + # Экспорт + csv = budget_data.to_csv(index=False, sep=';', decimal=',').encode('utf-8-sig') + + st.download_button( + label="📥 Скачать отчёт (CSV)", + data=csv, + file_name=f"budget_forecast_2027_{datetime.now().strftime('%Y%m%d')}.csv", + mime="text/csv" + ) + +def render_footer(): + """Подвал с политикой конфиденциальности""" + st.markdown("---") + st.markdown(""" +
+ ⚠️ Внимание: информация носит строго конфиденциальный характер. + Я передаю данные для обработки, но требую хранить их в безопасности и не передавать третьим лицам + согласно закону. Запрещено размещение в открытом доступе. +
+ """, unsafe_allow_html=True) + + st.markdown(""" +
+ BI-дашборд Автобазы v1.0 | Внутренний корпоративный сервис | 2024 +
+ """, unsafe_allow_html=True) + +# ============================================================================ +# ГЛАВНОЕ ПРИЛОЖЕНИЕ +# ============================================================================ + +def main(): + init_session_state() + + # Проверка авторизации + if not st.session_state.authenticated: + login_page() + render_footer() + return + + # Боковая панель + with st.sidebar: + st.markdown(f"👤 **{st.session_state.username}**") + st.markdown(f"Роль: {st.session_state.user_role}") + st.markdown("---") + + if st.button("🚪 Выйти"): + st.session_state.authenticated = False + st.session_state.user_role = None + st.session_state.username = None + st.rerun() + + st.markdown("---") + st.markdown("### 📊 Разделы") + + # Инициализация данных + if st.session_state.data_fetcher is None: + # Используем mock-данные для демо + st.session_state.data_fetcher = MockDataFetcher() + st.session_state.calculator = KPICalculator(st.session_state.data_fetcher) + st.session_state.calculator.load_all_data() + + calculator = st.session_state.calculator + + # Навигация + pages = { + "📈 Дашборд": "dashboard", + "🏆 Рейтинг": "rating", + "💰 Бюджет 2027": "budget" + } + + # Фильтрация меню по ролям + if 'view_finance' not in st.session_state.permissions: + pages.pop("💰 Бюджет 2027", None) + + selected = st.radio("Перейти к:", list(pages.keys()), index=0, label_visibility="collapsed") + current_page = pages[selected] + + st.markdown("---") + st.markdown("### 🔧 Настройки") + + # Выбор источника данных + data_source = st.selectbox( + "Источник данных:", + ["Mock (демо)", "Google Sheets", "API (ERP)"], + index=0 + ) + + if data_source == "Google Sheets": + st.warning("Требуется настройка service_account.json") + + st.markdown("---") + st.markdown(f"Последнее обновление: {datetime.now().strftime('%d.%m.%Y %H:%M')}") + + # Основной контент + if current_page == "dashboard": + st.title("📈 Дашборд эффективности автобаз") + + # KPI карточки + render_kpi_cards(calculator) + + st.markdown("---") + + # Графики + render_charts(calculator) + + # Детальные данные + with st.expander("📋 Детальные данные по автопарку"): + st.dataframe(calculator._cache['fleet'], use_container_width=True) + + with st.expander("📊 Статистика по заявкам"): + st.dataframe(calculator._cache['requests'], use_container_width=True) + + elif current_page == "rating": + st.title("🏆 Рейтинг подразделений") + + # Сводная таблица + rating_df = calculator.calc_department_rating() + render_rating_table(rating_df) + + st.markdown("---") + + # Детализация по выбранному подразделению + st.subheader("🔍 Детализация по подразделению") + + selected_dept = st.selectbox( + "Выберите подразделение:", + rating_df['Подразделение'].tolist() + ) + + dept_data = rating_df[rating_df['Подразделение'] == selected_dept].iloc[0] + + col1, col2, col3 = st.columns(3) + + with col1: + st.metric("Исполнение заявок", f"{dept_data['Исполнение заявок']}%") + st.metric("КИП", f"{dept_data['КИП']}%") + + with col2: + st.metric("Безопасность", f"{dept_data['Безопасность']}") + st.metric("Эксплуатация", f"{dept_data['Эксплуатация']}") + + with col3: + st.metric("Перерасход ГСМ", f"{dept_data['ГСМ перерасход %']}%") + st.metric("Стоимость 1 км", f"{dept_data['Стоимость 1 км']} ₽") + + elif current_page == "budget": + if 'view_finance' not in st.session_state.permissions: + st.error("⛔ Недостаточно прав для просмотра финансового раздела") + return + + render_budget_report(calculator) + + # Подвал на всех страницах + render_footer() + +if __name__ == "__main__": + main() diff --git a/data_fetcher.py b/data_fetcher.py new file mode 100644 index 0000000..aa34b25 --- /dev/null +++ b/data_fetcher.py @@ -0,0 +1,389 @@ +""" +Data Fetcher Module - Абстракция доступа к данным +Repository Pattern: интерфейс DataFetcher с реализациями для разных источников. + +Фаза 1: GoogleSheetsFetcher (текущая реализация) +Фаза 2: ApiFetcher (будущая реализация для ERP) +""" + +from abc import ABC, abstractmethod +from typing import List, Dict, Any, Optional +import pandas as pd +from datetime import datetime + + +class DataFetcher(ABC): + """ + Интерфейс для получения данных. + Все реализации должны следовать этому контракту. + """ + + @abstractmethod + def get_fleet_data(self) -> pd.DataFrame: + """Получить данные по автопарку""" + pass + + @abstractmethod + def get_requests_data(self) -> pd.DataFrame: + """Получить данные по заявкам""" + pass + + @abstractmethod + def get_accidents_data(self) -> pd.DataFrame: + """Получить данные по ДТП и нарушениям""" + pass + + @abstractmethod + def get_maintenance_data(self) -> pd.DataFrame: + """Получить данные по ТО и ремонтам""" + pass + + @abstractmethod + def get_fuel_data(self) -> pd.DataFrame: + """Получить данные по ГСМ и финансам""" + pass + + @abstractmethod + def get_employees_data(self) -> pd.DataFrame: + """Получить данные по сотрудникам""" + pass + + +class GoogleSheetsFetcher(DataFetcher): + """ + Реализация получения данных из Google Sheets. + Требуется сервисный аккаунт с доступом к таблицам. + """ + + def __init__(self, service_account_path: str, sheet_id: str): + """ + Инициализация коннектора к Google Sheets. + + Args: + service_account_path: Путь к JSON-ключу сервисного аккаунта + sheet_id: ID Google таблицы (из URL) + """ + self.service_account_path = service_account_path + self.sheet_id = sheet_id + self._client = None + self._spreadsheet = None + + def _get_client(self): + """Ленивая инициализация клиента Google Sheets API""" + if self._client is None: + import gspread + from google.auth import service_account + + credentials = service_account.Credentials.from_service_account_file( + self.service_account_path, + scopes=['https://www.googleapis.com/auth/spreadsheets'] + ) + self._client = gspread.authorize(credentials) + self._spreadsheet = self._client.open_by_key(self.sheet_id) + + return self._spreadsheet + + def _read_worksheet(self, worksheet_name: str) -> pd.DataFrame: + """ + Прочитать лист из Google Sheets в DataFrame. + + Args: + worksheet_name: Название листа + + Returns: + DataFrame с данными + """ + try: + spreadsheet = self._get_client() + worksheet = spreadsheet.worksheet(worksheet_name) + data = worksheet.get_all_values() + + if not data or len(data) < 2: + return pd.DataFrame() + + # Первая строка - заголовки + headers = data[0] + rows = data[1:] + + df = pd.DataFrame(rows, columns=headers) + return df + + except Exception as e: + print(f"Ошибка чтения листа '{worksheet_name}': {e}") + return pd.DataFrame() + + def get_fleet_data(self) -> pd.DataFrame: + """ + Получить данные по автопарку. + Ожидаемые колонки: vehicle_id, vehicle_type, department, status, year, mileage + """ + df = self._read_worksheet("Автопарк") + return df + + def get_requests_data(self) -> pd.DataFrame: + """ + Получить данные по заявкам. + Ожидаемые колонки: request_id, department, date, status, fulfilled, vehicle_id + """ + df = self._read_worksheet("Заявки") + return df + + def get_accidents_data(self) -> pd.DataFrame: + """ + Получить данные по ДТП и нарушениям ПДД. + Ожидаемые колонки: incident_id, date, vehicle_id, driver_id, type, severity, fine_amount + """ + df = self._read_worksheet("ДТП_ПДД") + return df + + def get_maintenance_data(self) -> pd.DataFrame: + """ + Получить данные по ТО и ремонтам. + Ожидаемые колонки: maintenance_id, vehicle_id, date, type, downtime_days, cost + """ + df = self._read_worksheet("ТО_Ремонты") + return df + + def get_fuel_data(self) -> pd.DataFrame: + """ + Получить данные по ГСМ и финансам. + Ожидаемые колонки: vehicle_id, date, fuel_liters, fuel_cost, planned_cost, actual_cost + """ + df = self._read_worksheet("ГСМ_Финансы") + return df + + def get_employees_data(self) -> pd.DataFrame: + """ + Получить данные по сотрудникам. + Ожидаемые колонки: employee_id, name, department, position, license_category + """ + df = self._read_worksheet("Сотрудники") + return df + + +class MockDataFetcher(DataFetcher): + """ + Тестовая реализация с mock-данными. + Используется для демонстрации и разработки без подключения к Google Sheets. + """ + + def __init__(self): + """Инициализация с тестовыми данными""" + self.departments = ["Автобаза №1", "Автобаза №2", "Автобаза №3", "Спецтехника"] + + def get_fleet_data(self) -> pd.DataFrame: + """Сгенерировать тестовые данные по автопарку""" + data = { + 'vehicle_id': [f'V{i:03d}' for i in range(1, 51)], + 'vehicle_type': ['Грузовой'] * 20 + ['Легковой'] * 15 + ['Спецтехника'] * 15, + 'department': self.departments * 12 + self.departments[:2], + 'status': ['В работе'] * 35 + ['В ремонте'] * 8 + ['Резерв'] * 7, + 'year': [2020, 2021, 2022, 2023, 2024] * 10, + 'mileage': [50000, 75000, 120000, 95000, 30000] * 10, + 'purchase_cost': [2500000, 1800000, 3200000, 2100000, 4500000] * 10 + } + df = pd.DataFrame(data) + df['mileage'] = df['mileage'] * (1 + pd.Series(range(50)) % 5 * 0.2) + return df + + def get_requests_data(self) -> pd.DataFrame: + """Сгенерировать тестовые данные по заявкам""" + import random + from datetime import datetime, timedelta + + data = [] + base_date = datetime(2024, 1, 1) + + for i in range(500): + dept = random.choice(self.departments) + date = base_date + timedelta(days=random.randint(0, 365)) + fulfilled = random.random() > 0.15 # 85% исполнения + + data.append({ + 'request_id': f'R{i+1:04d}', + 'department': dept, + 'date': date.strftime('%Y-%m-%d'), + 'status': 'Исполнено' if fulfilled else 'Отклонено', + 'fulfilled': 1 if fulfilled else 0, + 'vehicle_id': f'V{random.randint(1, 50):03d}' + }) + + return pd.DataFrame(data) + + def get_accidents_data(self) -> pd.DataFrame: + """Сгенерировать тестовые данные по ДТП""" + import random + from datetime import datetime, timedelta + + data = [] + base_date = datetime(2024, 1, 1) + incident_types = ['ДТП', 'Нарушение ПДД', 'Инцидент'] + severities = ['Лёгкий', 'Средний', 'Тяжёлый'] + + for i in range(25): + date = base_date + timedelta(days=random.randint(0, 365)) + severity = random.choice(severities) + fine = random.randint(500, 50000) if severity != 'Лёгкий' else random.randint(0, 5000) + + data.append({ + 'incident_id': f'I{i+1:03d}', + 'date': date.strftime('%Y-%m-%d'), + 'vehicle_id': f'V{random.randint(1, 50):03d}', + 'driver_id': f'D{random.randint(1, 30):03d}', + 'type': random.choice(incident_types), + 'severity': severity, + 'fine_amount': fine + }) + + return pd.DataFrame(data) + + def get_maintenance_data(self) -> pd.DataFrame: + """Сгенерировать тестовые данные по ТО""" + import random + from datetime import datetime, timedelta + + data = [] + base_date = datetime(2024, 1, 1) + maintenance_types = ['Плановое ТО', 'Внеплановый ремонт', 'Диагностика'] + + for i in range(120): + date = base_date + timedelta(days=random.randint(0, 365)) + mtype = random.choice(maintenance_types) + downtime = random.randint(1, 15) if mtype != 'Диагностика' else random.randint(0, 2) + cost = random.randint(5000, 150000) + + data.append({ + 'maintenance_id': f'M{i+1:04d}', + 'vehicle_id': f'V{random.randint(1, 50):03d}', + 'date': date.strftime('%Y-%m-%d'), + 'type': mtype, + 'downtime_days': downtime, + 'cost': cost + }) + + return pd.DataFrame(data) + + def get_fuel_data(self) -> pd.DataFrame: + """Сгенерировать тестовые данные по ГСМ""" + import random + from datetime import datetime, timedelta + + data = [] + base_date = datetime(2024, 1, 1) + + for i in range(600): + date = base_date + timedelta(days=random.randint(0, 365)) + vehicle_id = f'V{random.randint(1, 50):03d}' + planned = random.randint(8000, 15000) + actual = int(planned * (0.85 + random.random() * 0.3)) # +/- 15% + + data.append({ + 'vehicle_id': vehicle_id, + 'date': date.strftime('%Y-%m-%d'), + 'fuel_liters': random.randint(100, 500), + 'fuel_cost': actual, + 'planned_cost': planned, + 'actual_cost': actual + }) + + return pd.DataFrame(data) + + def get_employees_data(self) -> pd.DataFrame: + """Сгенерировать тестовые данные по сотрудникам""" + import random + + first_names = ['Александр', 'Дмитрий', 'Сергей', 'Андрей', 'Михаил', + 'Владимир', 'Николай', 'Евгений', 'Алексей', 'Игорь'] + last_names = ['Иванов', 'Петров', 'Сидоров', 'Смирнов', 'Козлов', + 'Новиков', 'Федоров', 'Морозов', 'Волков', 'Лебедев'] + + data = { + 'employee_id': [f'D{i:03d}' for i in range(1, 31)], + 'name': [f'{random.choice(last_names)} {random.choice(first_names)}' + for _ in range(30)], + 'department': self.departments * 7 + self.departments[:2], + 'position': ['Водитель'] * 25 + ['Механик'] * 3 + ['Начальник'] * 2, + 'license_category': ['B', 'C', 'D', 'E'] * 7 + ['B', 'C'] + } + + return pd.DataFrame(data) + + +class ApiFetcher(DataFetcher): + """ + Заготовка для будущей реализации REST API коннектора к ERP системе. + Заполните методы согласно документации вашей ERP. + """ + + def __init__(self, api_base_url: str, api_token: str): + """ + Инициализация API коннектора. + + Args: + api_base_url: Базовый URL ERP API + api_token: Токен авторизации + """ + self.api_base_url = api_base_url + self.api_token = api_token + self._session = None + + def _get_headers(self) -> Dict[str, str]: + """Получить заголовки для API запросов""" + return { + 'Authorization': f'Bearer {self.api_token}', + 'Content-Type': 'application/json' + } + + def _fetch_endpoint(self, endpoint: str, params: Optional[Dict] = None) -> List[Dict]: + """ + Выполнить GET запрос к API. + + Args: + endpoint: Относительный путь endpoint + params: Параметры запроса + + Returns: + Список записей + """ + import requests + + if self._session is None: + self._session = requests.Session() + + url = f"{self.api_base_url}/{endpoint}" + response = self._session.get(url, headers=self._get_headers(), params=params) + response.raise_for_status() + + return response.json() + + def get_fleet_data(self) -> pd.DataFrame: + """Получить данные по автопарку из ERP""" + # TODO: Реализовать согласно документации ERP API + # data = self._fetch_endpoint('fleet/vehicles', {'year': 2024}) + # return pd.DataFrame(data) + raise NotImplementedError("Метод get_fleet_data не реализован для API") + + def get_requests_data(self) -> pd.DataFrame: + """Получить данные по заявкам из ERP""" + # TODO: Реализовать согласно документации ERP API + raise NotImplementedError("Метод get_requests_data не реализован для API") + + def get_accidents_data(self) -> pd.DataFrame: + """Получить данные по ДТП из ERP""" + # TODO: Реализовать согласно документации ERP API + raise NotImplementedError("Метод get_accidents_data не реализован для API") + + def get_maintenance_data(self) -> pd.DataFrame: + """Получить данные по ТО из ERP""" + # TODO: Реализовать согласно документации ERP API + raise NotImplementedError("Метод get_maintenance_data не реализован для API") + + def get_fuel_data(self) -> pd.DataFrame: + """Получить данные по ГСМ из ERP""" + # TODO: Реализовать согласно документации ERP API + raise NotImplementedError("Метод get_fuel_data не реализован для API") + + def get_employees_data(self) -> pd.DataFrame: + """Получить данные по сотрудникам из ERP""" + # TODO: Реализовать согласно документации ERP API + raise NotImplementedError("Метод get_employees_data не реализован для API") diff --git a/design.md b/design.md new file mode 100644 index 0000000..1a16f8b --- /dev/null +++ b/design.md @@ -0,0 +1,142 @@ + +# Design system — Vibe42 песочница + +Базовые цвета и типографика для лендингов. Начинай с этого, можно отклоняться. + +## Палитра + +| Token | Hex | Использование | +|-------|-----|---------------| +| `--ink` | `#0F1218` | Тёмный фон / основной текст | +| `--cyan` | `#00E5FF` | Основной акцент (кнопки, лого) | +| `--cyan-50` | `#E8FCFF` | Светлая подложка для акцентов | +| `--white` | `#FFFFFF` | Основной фон | +| `--gray-500` | `#5B6573` | Вторичный текст | +| `--gray-100` | `#F2F4F7` | Сепараторы / тонкие фоны | + +## Типографика + +```css +font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, sans-serif; +``` + +| Уровень | Размер | Вес | line-height | +|---------|--------|-----|-------------| +| h1 (hero) | 56px | 800 | 1.05 | +| h2 (section) | 36px | 700 | 1.15 | +| h3 | 22px | 700 | 1.3 | +| body | 17px | 400 | 1.6 | + +На мобиле — h1 → 36px, h2 → 28px. + +## Лейаут + +- max-width контента: **1140px** +- секция: `padding: 80px 24px` (мобила: `48px 20px`) +- border-radius: `8px` (кнопки), `16px` (карточки) + +## Стартер `index.html` + +```html + + + + + +Мой проект + + + +
+

Заголовок

+

Подзаголовок — пара предложений о чём это.

+ Начать +
+
+

Секция

+
Контент карточки.
+
Контент карточки.
+
+ + +``` + +## Что НЕ юзать + +- Bootstrap / Material UI / Chakra / Ant Design — слишком тяжело +- Font Awesome — используй emoji (🚀 ⚡ ✨) или inline SVG +- jQuery — vanilla JS достаточно + +## Что МОЖНО (если очень надо) + +- **Tailwind через CDN**: `` +- **Placeholder картинки**: `https://picsum.photos/800/600` +- **Google Fonts через ``** в head + +## 📦 Готовые рецепты (используй их, не изобретай) + +### Фото без ключей +- Детерминированные (одно и то же слово → одна и та же картинка): `https://picsum.photos/seed/coffee/800/600`. Меняй `coffee` на любое слово-seed. +- Тематические (по тегу): `https://loremflickr.com/800/600/coffee` — тег прямо в URL (`/coffee`, `/mountains`, `/food`). Тема не гарантируется на 100%, но обычно в кассу. + +### Кнопка WhatsApp +```html +Написать в WhatsApp +``` +`7XXXXXXXXXX` — номер в межд. формате без `+`. `text=` — предзаполненное сообщение. + +### Форма заявки без бэкенда +```html +
+ + + +
+``` +Скажи юзеру: замени `EMAIL` на свою почту — заявки будут приходить туда (первая заявка активирует, письмо-подтверждение придёт один раз). + +### Google Fonts (максимум 2 шрифта: один display + один text) +```html + + +``` +В CSS: `font-family:'Unbounded',sans-serif` для заголовков, `'Manrope',sans-serif` для текста. + +### Favicon-эмодзи (inline SVG data-URI, без файла) +```html + +``` +Меняй эмодзи `☕` на подходящее теме. + +### Карта (Google Maps embed, без ключа) +На maps.google.com найди место → «Поделиться» → «Встроить карту» → скопируй iframe. Вставь как есть: +```html + +``` + +### Плавные появления при скролле (IntersectionObserver) +```html + + +``` +Добавь класс `reveal` любой секции/карточке — появится плавно при прокрутке. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..57c2f94 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +streamlit==1.32.0 +pandas==2.2.1 +plotly==5.19.0 +gspread==6.0.0 +google-auth==2.28.1 +python-dotenv==1.0.1