From dfd855cb6ed79d4c6c34c15051b1f106d3fbd0d9 Mon Sep 17 00:00:00 2001 From: SERTY Date: Fri, 26 Dec 2025 07:02:01 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D0=B2=D0=B5=D1=82=D1=81=D1=82=D0=B2=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D0=B9=20=D0=B1=D0=BE=D0=BD=D1=83=D1=81=20?= =?UTF-8?q?=D0=B8=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 33 ++ .../repository/account/postgres.go | 48 +- internal/transport/telegram/bot.go | 64 ++- rmser-view/project_context.md | 528 ++++++++++++++---- 4 files changed, 532 insertions(+), 141 deletions(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..c5ff1cd --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +**RMSer** — это SaaS-платформа, которая избавляет бухгалтеров и менеджеров ресторанов от рутинного ввода накладных. С помощью оптического распознавания (OCR) и алгоритмов сопоставления данных, сервис сокращает время приемки товара в несколько раз. + +## ✨ Ключевые преимущества + +### 🧠 Умный матчинг и самообучение +Интерфейс сопоставления позволяет один раз связать текст из чека поставщика («Томат черри 250г имп») с вашей позицией в iiko («Томаты Черри»). Система сохраняет эту связь и при следующих загрузках подставляет нужный товар автоматически. + +### ⚙️ Тонкая настройка под объект +* **Склад по умолчанию:** Автоматическая подстановка нужного склада для каждой новой накладной. +* **Корневая группа поиска:** Ограничьте область поиска товаров (например, только категория «Продукты»). Это исключает попадание в накладные услуг, инвентаря или блюд. +* **Авто-проведение:** Возможность настроить автоматическое создание накладной в статусе «Проведено» (Processed). +* **Добавление фасовок:** Для каждого товара из iiko можно добавить фасовку и текст из чека будет сопоставлен с ней. + +### 👥 Управление командой в приложении +Полноценный интерфейс управления пользователями внутри Telegram Mini App: +* **Owner:** Полный доступ и управление подпиской. +* **Admin:** Настройка интеграции и сопоставление товаров. +* **Operator:** Режим «только фото» — идеально для линейного персонала на приемке. +* **Инвайт-система:** Быстрое добавление сотрудников через ссылку. + +## 🛠 Стек технологий + +* **Language:** Go 1.25 +* **Framework:** Gin Gonic (REST API) +* **Database:** PostgreSQL 16 (GORM) +* **Cache:** Redis +* **Bot Engine:** Telebot v3 +* **Containerization:** Docker & Docker Compose +* **Security:** AES-256 (GCM) для шифрования учетных данных RMS. + +## 💳 Биллинг и Бонусы +Сервис работает по модели Pay-as-you-go (пакеты накладных) или по подписке. +**Welcome Bonus:** При подключении нового сервера система автоматически начисляет **10 накладных на 30 дней** для бесплатного тестирования. \ No newline at end of file diff --git a/internal/infrastructure/repository/account/postgres.go b/internal/infrastructure/repository/account/postgres.go index 76f2111..ef2d59d 100644 --- a/internal/infrastructure/repository/account/postgres.go +++ b/internal/infrastructure/repository/account/postgres.go @@ -72,27 +72,29 @@ func (r *pgRepository) GetUserByID(id uuid.UUID) (*account.User, error) { // ConnectServer - Основная точка входа для добавления сервера func (r *pgRepository) ConnectServer(userID uuid.UUID, rawURL, login, encryptedPass, name string) (*account.RMSServer, error) { - // 1. Нормализация URL (удаляем слеш в конце, приводим к нижнему регистру) - // Важно: мы не удаляем http/https, так как iiko может работать и так и так, но обычно это разные эндпоинты. - // Для надежности уникальности можно вырезать протокол, но пока оставим как есть, просто тримминг. cleanURL := strings.TrimRight(strings.ToLower(strings.TrimSpace(rawURL)), "/") var server account.RMSServer var created bool err := r.db.Transaction(func(tx *gorm.DB) error { - // 2. Ищем, существует ли сервер с таким URL err := tx.Where("base_url = ?", cleanURL).First(&server).Error if err != nil && err != gorm.ErrRecordNotFound { return err } if err == gorm.ErrRecordNotFound { - // --- СЦЕНАРИЙ 1: НОВЫЙ СЕРВЕР --- + // --- СЦЕНАРИЙ 1: НОВЫЙ СЕРВЕР (Приветственный бонус) --- + trialDays := 30 + welcomeBalance := 10 + paidUntil := time.Now().AddDate(0, 0, trialDays) + server = account.RMSServer{ - BaseURL: cleanURL, - Name: name, - MaxUsers: 5, // Дефолтное ограничение + BaseURL: cleanURL, + Name: name, + MaxUsers: 5, + Balance: welcomeBalance, + PaidUntil: &paidUntil, } if err := tx.Create(&server).Error; err != nil { return err @@ -100,11 +102,9 @@ func (r *pgRepository) ConnectServer(userID uuid.UUID, rawURL, login, encryptedP created = true } else { // --- СЦЕНАРИЙ 2: СУЩЕСТВУЮЩИЙ СЕРВЕР --- - // Проверяем лимит пользователей var userCount int64 tx.Model(&account.ServerUser{}).Where("server_id = ?", server.ID).Count(&userCount) if userCount >= int64(server.MaxUsers) { - // Проверяем, может пользователь УЖЕ там есть? Тогда это не добавление, а обновление кредов var exists int64 tx.Model(&account.ServerUser{}).Where("server_id = ? AND user_id = ?", server.ID, userID).Count(&exists) if exists == 0 { @@ -113,18 +113,24 @@ func (r *pgRepository) ConnectServer(userID uuid.UUID, rawURL, login, encryptedP } } - // 3. Определяем роль targetRole := account.RoleOperator if created { targetRole = account.RoleOwner } - // 4. Создаем или обновляем связь с пользователем - // Сбрасываем активность других серверов if err := tx.Model(&account.ServerUser{}).Where("user_id = ?", userID).Update("is_active", false).Error; err != nil { return err } + var existingLink account.ServerUser + err = tx.Where("server_id = ? AND user_id = ?", server.ID, userID).First(&existingLink).Error + if err == nil { + existingLink.Login = login + existingLink.EncryptedPassword = encryptedPass + existingLink.IsActive = true + return tx.Save(&existingLink).Error + } + userLink := account.ServerUser{ ServerID: server.ID, UserID: userID, @@ -133,22 +139,6 @@ func (r *pgRepository) ConnectServer(userID uuid.UUID, rawURL, login, encryptedP Login: login, EncryptedPassword: encryptedPass, } - - // Upsert для связи (на случай если пользователь подключает уже подключенный сервер, обновляем пароль) - // Если пользователь уже был OWNER/ADMIN, роль НЕ понижаем. - // Поэтому используем хитрый Upsert: обновляем роль только если запись новая. - - var existingLink account.ServerUser - err = tx.Where("server_id = ? AND user_id = ?", server.ID, userID).First(&existingLink).Error - if err == nil { - // Запись есть -> обновляем креды и делаем активным, роль НЕ меняем - existingLink.Login = login - existingLink.EncryptedPassword = encryptedPass - existingLink.IsActive = true - return tx.Save(&existingLink).Error - } - - // Записи нет -> создаем с вычисленной ролью return tx.Create(&userLink).Error }) diff --git a/internal/transport/telegram/bot.go b/internal/transport/telegram/bot.go index 21a9680..8dd91b9 100644 --- a/internal/transport/telegram/bot.go +++ b/internal/transport/telegram/bot.go @@ -156,7 +156,43 @@ func (bot *Bot) handleStartCommand(c tele.Context) error { if payload != "" && strings.HasPrefix(payload, "invite_") { return bot.handleInviteLink(c, strings.TrimPrefix(payload, "invite_")) } - return bot.renderMainMenu(c) + + welcomeTxt := "🚀 RMSer — ваш умный ассистент для iiko\n\n" + + "Больше не нужно вводить накладные вручную. Просто сфотографируйте чек, а я сделаю всё остальное.\n\n" + + "Почему это удобно:\n" + + "🧠 Самообучение: Сопоставьте товар один раз, и в следующий раз я узнаю его сам.\n" + + "⚙️ Гибкая настройка: Укажите склад по умолчанию и ограничьте область поиска товаров только нужными категориями.\n" + + "👥 Работа в команде: Приглашайте сотрудников, распределяйте роли и управляйте доступом прямо в Mini App.\n\n" + + "🎁 Старт без риска: Дарим 10 накладных на 30 дней каждому новому серверу!" + + return bot.renderMainMenuWithText(c, welcomeTxt) +} + +// Вспомогательный метод для рендера (чтобы не дублировать код меню) +func (bot *Bot) renderMainMenuWithText(c tele.Context, text string) error { + bot.fsm.Reset(c.Sender().ID) + userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID) + activeServer, _ := bot.accountRepo.GetActiveServer(userDB.ID) + + menu := &tele.ReplyMarkup{} + btnServers := menu.Data("🖥 Серверы", "nav_servers") + btnDicts := menu.Data("🔄 Справочники", "nav_dicts") + btnBalance := menu.Data("💰 Баланс", "nav_balance") + + var rows []tele.Row + rows = append(rows, menu.Row(btnServers, btnDicts)) + rows = append(rows, menu.Row(btnBalance)) + + if activeServer != nil { + role, _ := bot.accountRepo.GetUserRole(userDB.ID, activeServer.ID) + if role == account.RoleOwner || role == account.RoleAdmin { + btnApp := menu.WebApp("📱 Открыть приложение", &tele.WebApp{URL: bot.webAppURL}) + rows = append(rows, menu.Row(btnApp)) + } + } + + menu.Inline(rows...) + return c.EditOrSend(text, menu, tele.ModeHTML) } func (bot *Bot) handleInviteLink(c tele.Context, serverIDStr string) error { @@ -759,7 +795,7 @@ func (bot *Bot) handlePhoto(c tele.Context) error { if err != nil { return c.Send("Ошибка чтения файла.") } - c.Send("⏳ Обрабатываю чек: создаю черновик и распознаю...") + c.Send("⏳ ИИ анализирует документ...\nОбрабатываю позиции и ищу совпадения в вашей базе iiko.", tele.ModeHTML) ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) defer cancel() draft, err := bot.ocrService.ProcessReceiptImage(ctx, userDB.ID, imgData) @@ -777,14 +813,14 @@ func (bot *Bot) handlePhoto(c tele.Context) error { fullURL := fmt.Sprintf("%s/invoice/%s", baseURL, draft.ID.String()) var msgText string if matchedCount == len(draft.Items) { - msgText = fmt.Sprintf("✅ Успех! Все позиции (%d) распознаны.\n\nПереходите к созданию накладной.", len(draft.Items)) + msgText = fmt.Sprintf("✅ Все позиции распознаны!\n\nВсе товары найдены в вашей номенклатуре. Проверьте количество (%d) и цену в приложении перед отправкой.", len(draft.Items)) } else { - msgText = fmt.Sprintf("⚠️ Внимание! Распознано %d из %d позиций.\n\nНекоторые товары требуют ручного сопоставления.", matchedCount, len(draft.Items)) + msgText = fmt.Sprintf("⚠️ Распознано позиций: %d из %d\n\nОстальные товары я вижу впервые. Воспользуйтесь удобным интерфейсом сопоставления в приложении — я запомню ваш выбор навсегда.", matchedCount, len(draft.Items)) } menu := &tele.ReplyMarkup{} role, _ := bot.accountRepo.GetUserRole(userDB.ID, draft.RMSServerID) if role != account.RoleOperator { - btnOpen := menu.WebApp("📝 Открыть накладную", &tele.WebApp{URL: fullURL}) + btnOpen := menu.WebApp("📝 Открыть и сопоставить", &tele.WebApp{URL: fullURL}) menu.Inline(menu.Row(btnOpen)) } else { msgText += "\n\n(Редактирование доступно Администратору)" @@ -807,18 +843,32 @@ func (bot *Bot) handleConfirmNameNo(c tele.Context) error { return c.EditOrSend("🏷 Хорошо, введите желаемое название:") } +// Обновленный метод saveServerFinal (добавление уведомления о бонусе) func (bot *Bot) saveServerFinal(c tele.Context, userID int64, serverName string) error { ctx := bot.fsm.GetContext(userID) userDB, _ := bot.accountRepo.GetOrCreateUser(userID, c.Sender().Username, "", "") encPass, _ := bot.cryptoManager.Encrypt(ctx.TempPassword) + server, err := bot.accountRepo.ConnectServer(userDB.ID, ctx.TempURL, ctx.TempLogin, encPass, serverName) if err != nil { - return c.Send("Ошибка подключения сервера: " + err.Error()) + return c.Send("❌ Ошибка подключения сервера: " + err.Error()) } bot.fsm.Reset(userID) + role, _ := bot.accountRepo.GetUserRole(userDB.ID, server.ID) - c.Send(fmt.Sprintf("✅ Сервер %s подключен!\nВаша роль: %s", server.Name, role), tele.ModeHTML) + + successMsg := fmt.Sprintf("✅ Сервер %s успешно подключен!\nВаша роль: %s\n\n", server.Name, role) + + // Проверяем, новый ли это сервер по балансу и дате создания (упрощенно для уведомления) + if server.Balance == 10 { + successMsg += "🎁 Вам начислен приветственный бонус: 10 накладных на 30 дней! Пользуйтесь с удовольствием.\n\n" + } + + successMsg += "Начинаю первичную синхронизацию данных..." + + c.Send(successMsg, tele.ModeHTML) go bot.syncService.SyncAllData(userDB.ID) + return bot.renderMainMenu(c) } diff --git a/rmser-view/project_context.md b/rmser-view/project_context.md index bb97f3a..8c931bd 100644 --- a/rmser-view/project_context.md +++ b/rmser-view/project_context.md @@ -1,9 +1,9 @@ # =================================================================== -# Полный контекст Vue.js проекта -# Сгенерировано: 2025-12-23 08:36:21 +# Полный контекст React Typescript проекта +# Сгенерировано: 2025-12-26 05:56:35 # =================================================================== -Это полный дамп исходного кода Vue/Vite проекта. +Это полный дамп исходного кода React Typescript (Vite) проекта. Каждый файл предваряется заголовком с путём к нему. @@ -5990,6 +5990,219 @@ export const RecommendationCard: React.FC = ({ item }) => { }; ``` +# =================================================================== +# Файл: src/components/settings/TeamList.tsx +# =================================================================== + +``` +import React from "react"; +import { + List, + Avatar, + Tag, + Button, + Select, + Popconfirm, + message, + Spin, + Alert, + Typography, +} from "antd"; +import { DeleteOutlined, UserOutlined } from "@ant-design/icons"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { api } from "../../services/api"; +import type { ServerUser, UserRole } from "../../services/types"; + +const { Text } = Typography; + +interface Props { + currentUserRole: UserRole; +} + +export const TeamList: React.FC = ({ currentUserRole }) => { + const queryClient = useQueryClient(); + + // Запрос списка пользователей + const { + data: users, + isLoading, + isError, + } = useQuery({ + queryKey: ["serverUsers"], + queryFn: api.getUsers, + }); + + // Мутация изменения роли + const updateRoleMutation = useMutation({ + mutationFn: ({ userId, newRole }: { userId: string; newRole: UserRole }) => + api.updateUserRole(userId, newRole), + onSuccess: () => { + message.success("Роль пользователя обновлена"); + queryClient.invalidateQueries({ queryKey: ["serverUsers"] }); + }, + onError: () => { + message.error("Не удалось изменить роль"); + }, + }); + + // Мутация удаления пользователя + const removeUserMutation = useMutation({ + mutationFn: (userId: string) => api.removeUser(userId), + onSuccess: () => { + message.success("Пользователь удален из команды"); + queryClient.invalidateQueries({ queryKey: ["serverUsers"] }); + }, + onError: () => { + message.error("Не удалось удалить пользователя"); + }, + }); + + // Хелперы для UI + const getRoleColor = (role: UserRole) => { + switch (role) { + case "OWNER": + return "gold"; + case "ADMIN": + return "blue"; + case "OPERATOR": + return "default"; + default: + return "default"; + } + }; + + const getRoleName = (role: UserRole) => { + switch (role) { + case "OWNER": + return "Владелец"; + case "ADMIN": + return "Админ"; + case "OPERATOR": + return "Оператор"; + default: + return role; + } + }; + + // Проверка прав на удаление + const canDelete = (targetUser: ServerUser) => { + if (targetUser.is_me) return false; // Себя удалить нельзя + if (targetUser.role === "OWNER") return false; // Владельца удалить нельзя + if (currentUserRole === "ADMIN" && targetUser.role === "ADMIN") + return false; // Админ не может удалить админа + return true; + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError) { + return ; + } + + return ( + <> + + + ( + + updateRoleMutation.mutate({ + userId: user.user_id, + newRole: val, + }) + } + options={[ + { value: "ADMIN", label: "Админ" }, + { value: "OPERATOR", label: "Оператор" }, + ]} + /> + ) : ( + + {getRoleName(user.role)} + + ), + + // Кнопка удаления + removeUserMutation.mutate(user.user_id)} + disabled={!canDelete(user)} + okText="Да" + cancelText="Нет" + > + + {/* Правая часть хедера: Кнопка чека и Кнопка удаления */} +
+ {/* Кнопка просмотра чека (только если есть URL) */} + {draft.photo_url && ( + + )} + + +
{/* Form: Склады и Поставщики */} @@ -6671,7 +6900,7 @@ export const InvoiceDraftPage: React.FC = () => { type="dashed" block icon={} - style={{ marginTop: 12, marginBottom: 80, height: 48 }} // Увеличенный margin bottom для Affix + style={{ marginTop: 12, marginBottom: 80, height: 48 }} onClick={() => addItemMutation.mutate()} loading={addItemMutation.isPending} disabled={isCanceled} @@ -6725,6 +6954,22 @@ export const InvoiceDraftPage: React.FC = () => { + + {/* Скрытый компонент для просмотра изображения */} + {draft.photo_url && ( +
+ setPreviewVisible(vis), + movable: true, + scaleStep: 0.5, + }} + > + + +
+ )} ); }; @@ -6809,16 +7054,19 @@ import { TreeSelect, Spin, message, + Tabs, } from "antd"; import { SaveOutlined, BarChartOutlined, SettingOutlined, FolderOpenOutlined, + TeamOutlined, } from "@ant-design/icons"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { api } from "../services/api"; import type { UserSettings } from "../services/types"; +import { TeamList } from "../components/settings/TeamList"; const { Title, Text } = Typography; @@ -6892,6 +7140,115 @@ export const SettingsPage: React.FC = () => { ); } + // Определяем роль текущего пользователя + const currentUserRole = settingsQuery.data?.role || "OPERATOR"; + const showTeamSettings = + currentUserRole === "ADMIN" || currentUserRole === "OWNER"; + + // Сохраняем JSX в переменную вместо создания вложенного компонента + const generalSettingsContent = ( +
+ + + ({ - label: s.name, - value: s.id, - }))} - /> - - - - } - loading={groupsQuery.isLoading} - /> - - - -
-
- Проводить накладные автоматически -
- - Если выключено, накладные в iiko будут создаваться как - "Непроведенные" - -
- -
-
-
- - -
+ {/* Табы настроек */} + ); }; @@ -7062,11 +7337,21 @@ import type { AddContainerRequest, AddContainerResponse, DictionariesResponse, - DraftSummary + DraftSummary, + ServerUser, + UserRole } from './types'; // Базовый URL -const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api'; +export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api'; + +// Хелпер для получения полного URL картинки (убирает /api если путь статики идет от корня, или добавляет как есть) +// В данном ТЗ сказано просто склеивать. +export const getStaticUrl = (path: string | null | undefined): string => { + if (!path) return ''; + if (path.startsWith('http')) return path; + return `${API_BASE_URL}${path}`; +}; // Телеграм объект const tg = window.Telegram?.WebApp; @@ -7075,7 +7360,7 @@ const tg = window.Telegram?.WebApp; export const UNAUTHORIZED_EVENT = 'rms_unauthorized'; const apiClient = axios.create({ - baseURL: API_URL, + baseURL: API_BASE_URL, headers: { 'Content-Type': 'application/json', }, @@ -7243,6 +7528,23 @@ export const api = { const { data } = await apiClient.get('/dictionaries/groups'); return data; }, + + // --- Управление командой --- + + getUsers: async (): Promise => { + const { data } = await apiClient.get('/settings/users'); + return data; + }, + + updateUserRole: async (userId: string, newRole: UserRole): Promise<{ status: string }> => { + const { data } = await apiClient.patch<{ status: string }>(`/settings/users/${userId}`, { new_role: newRole }); + return data; + }, + + removeUser: async (userId: string): Promise<{ status: string }> => { + const { data } = await apiClient.delete<{ status: string }>(`/settings/users/${userId}`); + return data; + }, }; ``` @@ -7255,6 +7557,20 @@ export const api = { export type UUID = string; +// Добавляем типы ролей +export type UserRole = 'OWNER' | 'ADMIN' | 'OPERATOR'; + +// Интерфейс пользователя сервера +export interface ServerUser { + user_id: string; + username: string; // @username или пустая строка + first_name: string; + last_name: string; + photo_url: string; // URL картинки или пустая строка + role: UserRole; + is_me: boolean; // Флаг, является ли этот юзер текущим пользователем +} + // --- Каталог и Фасовки (API v2.0) --- export interface ProductContainer { @@ -7386,6 +7702,7 @@ export interface UserSettings { root_group_id: UUID | null; default_store_id: UUID | null; auto_conduct: boolean; + role: UserRole; // Добавляем поле роли в настройки текущего пользователя } export interface InvoiceStats { @@ -7449,6 +7766,7 @@ export interface DraftInvoice { comment: string; items: DraftItem[]; created_at?: string; + photo_url?: string; // Добавлено поле фото чека } // DTO для обновления строки