mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
добавил пользователей для сервера и роли
добавил инвайт-ссылки с ролью оператор для сервера добавил супер-админку для смены владельцев добавил уведомления о смене ролей на серверах добавил модалку для фото прям в черновике добавил UI для редактирования прав
This commit is contained in:
@@ -6,6 +6,15 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Роли пользователей
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleOwner Role = "OWNER" // Создатель: Полный доступ + удаление сервера
|
||||
RoleAdmin Role = "ADMIN" // Администратор: Редактирование, настройки, приглашение
|
||||
RoleOperator Role = "OPERATOR" // Оператор: Только загрузка фото
|
||||
)
|
||||
|
||||
// User - Пользователь системы (Telegram аккаунт)
|
||||
type User struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
@@ -15,53 +24,94 @@ type User struct {
|
||||
LastName string `gorm:"type:varchar(100)" json:"last_name"`
|
||||
PhotoURL string `gorm:"type:text" json:"photo_url"`
|
||||
|
||||
IsAdmin bool `gorm:"default:false" json:"is_admin"`
|
||||
|
||||
// Связь с серверами
|
||||
Servers []RMSServer `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"servers,omitempty"`
|
||||
IsSystemAdmin bool `gorm:"default:false" json:"is_system_admin"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// RMSServer - Настройки подключения к iikoRMS
|
||||
// ServerUser - Связь пользователя с сервером (здесь храним личные креды)
|
||||
type ServerUser struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"`
|
||||
ServerID uuid.UUID `gorm:"type:uuid;not null;index:idx_user_server,unique"`
|
||||
UserID uuid.UUID `gorm:"type:uuid;not null;index:idx_user_server,unique"`
|
||||
|
||||
Role Role `gorm:"type:varchar(20);default:'OPERATOR'"`
|
||||
IsActive bool `gorm:"default:false"` // Выбран ли этот сервер сейчас
|
||||
|
||||
// Персональные данные для подключения (могут быть null у операторов)
|
||||
Login string `gorm:"type:varchar(100)"`
|
||||
EncryptedPassword string `gorm:"type:text"`
|
||||
|
||||
Server RMSServer `gorm:"foreignKey:ServerID"`
|
||||
User User `gorm:"foreignKey:UserID"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// RMSServer - Инстанс сервера iiko
|
||||
type RMSServer struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id"`
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
|
||||
Name string `gorm:"type:varchar(100);not null" json:"name"` // Название (напр. "Ресторан на Ленина")
|
||||
// Уникальный URL (очищенный), определяет инстанс
|
||||
BaseURL string `gorm:"type:varchar(255);not null;uniqueIndex" json:"base_url"`
|
||||
|
||||
// Credentials
|
||||
BaseURL string `gorm:"type:varchar(255);not null" json:"base_url"`
|
||||
Login string `gorm:"type:varchar(100);not null" json:"login"`
|
||||
EncryptedPassword string `gorm:"type:text;not null" json:"-"` // Пароль храним зашифрованным
|
||||
Name string `gorm:"type:varchar(100);not null" json:"name"`
|
||||
MaxUsers int `gorm:"default:5" json:"max_users"` // Лимит пользователей
|
||||
|
||||
DefaultStoreID *uuid.UUID `gorm:"type:uuid" json:"default_store_id"` // Склад для подстановки
|
||||
RootGroupGUID *uuid.UUID `gorm:"type:uuid" json:"root_group_guid"` // ID корневой папки для поиска товаров
|
||||
AutoProcess bool `gorm:"default:false" json:"auto_process"` // Пытаться сразу проводить накладную
|
||||
// Глобальные настройки сервера (общие для всех)
|
||||
DefaultStoreID *uuid.UUID `gorm:"type:uuid" json:"default_store_id"`
|
||||
RootGroupGUID *uuid.UUID `gorm:"type:uuid" json:"root_group_guid"`
|
||||
AutoProcess bool `gorm:"default:false" json:"auto_process"`
|
||||
|
||||
// Billing / Stats
|
||||
InvoiceCount int `gorm:"default:0" json:"invoice_count"` // Счетчик успешно отправленных накладных
|
||||
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
InvoiceCount int `gorm:"default:0" json:"invoice_count"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Repository интерфейс управления аккаунтами
|
||||
// Repository интерфейс
|
||||
type Repository interface {
|
||||
// Users
|
||||
GetOrCreateUser(telegramID int64, username, first, last string) (*User, error)
|
||||
GetUserByTelegramID(telegramID int64) (*User, error)
|
||||
|
||||
// Servers
|
||||
SaveServer(server *RMSServer) error
|
||||
// ConnectServer - Основной метод подключения.
|
||||
// Реализует логику: Новый URL -> Owner, Старый URL -> Operator.
|
||||
ConnectServer(userID uuid.UUID, url, login, encryptedPass, name string) (*RMSServer, error)
|
||||
|
||||
SaveServerSettings(server *RMSServer) error
|
||||
|
||||
// SetActiveServer переключает активность в таблице ServerUser
|
||||
SetActiveServer(userID, serverID uuid.UUID) error
|
||||
GetActiveServer(userID uuid.UUID) (*RMSServer, error) // Получить активный (первый попавшийся или помеченный)
|
||||
GetAllServers(userID uuid.UUID) ([]RMSServer, error)
|
||||
|
||||
// GetActiveServer ищет сервер, где у UserID стоит флаг IsActive=true
|
||||
GetActiveServer(userID uuid.UUID) (*RMSServer, error)
|
||||
|
||||
// GetActiveConnectionCredentials возвращает актуальные логин/пароль для текущего юзера (личные или общие)
|
||||
GetActiveConnectionCredentials(userID uuid.UUID) (url, login, passHash string, err error)
|
||||
|
||||
// GetAllAvailableServers возвращает все серверы, доступные пользователю (в любом статусе)
|
||||
GetAllAvailableServers(userID uuid.UUID) ([]RMSServer, error)
|
||||
DeleteServer(serverID uuid.UUID) error
|
||||
|
||||
// Billing
|
||||
// GetUserRole возвращает роль пользователя на сервере (или ошибку доступа)
|
||||
GetUserRole(userID, serverID uuid.UUID) (Role, error)
|
||||
SetUserRole(serverID, targetUserID uuid.UUID, newRole Role) error
|
||||
GetServerUsers(serverID uuid.UUID) ([]ServerUser, error)
|
||||
|
||||
// Invite System
|
||||
AddUserToServer(serverID, userID uuid.UUID, role Role) error
|
||||
RemoveUserFromServer(serverID, userID uuid.UUID) error
|
||||
|
||||
IncrementInvoiceCount(serverID uuid.UUID) error
|
||||
|
||||
// Super Admin Functions
|
||||
GetAllServersSystemWide() ([]RMSServer, error)
|
||||
TransferOwnership(serverID, newOwnerID uuid.UUID) error
|
||||
|
||||
// GetConnectionByID получает связь ServerUser по её ID (нужно для админки, чтобы сократить callback_data)
|
||||
GetConnectionByID(id uuid.UUID) (*ServerUser, error)
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ type DraftInvoice struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
|
||||
// Привязка к аккаунту и серверу
|
||||
UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id"`
|
||||
RMSServerID uuid.UUID `gorm:"type:uuid;not null;index" json:"rms_server_id"`
|
||||
UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id"` // Кто загрузил (автор)
|
||||
RMSServerID uuid.UUID `gorm:"type:uuid;not null;index" json:"rms_server_id"` // К какому серверу относится
|
||||
|
||||
SenderPhotoURL string `gorm:"type:text" json:"photo_url"`
|
||||
Status string `gorm:"type:varchar(50);default:'PROCESSING'" json:"status"`
|
||||
@@ -73,5 +73,7 @@ type Repository interface {
|
||||
CreateItem(item *DraftInvoiceItem) error
|
||||
DeleteItem(itemID uuid.UUID) error
|
||||
Delete(id uuid.UUID) error
|
||||
GetActive(userID uuid.UUID) ([]DraftInvoice, error)
|
||||
|
||||
// GetActive возвращает активные черновики для СЕРВЕРА (а не юзера)
|
||||
GetActive(serverID uuid.UUID) ([]DraftInvoice, error)
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ func NewPostgresDB(dsn string) *gorm.DB {
|
||||
err = db.AutoMigrate(
|
||||
&account.User{},
|
||||
&account.RMSServer{},
|
||||
&account.ServerUser{},
|
||||
&catalog.Product{},
|
||||
&catalog.MeasureUnit{},
|
||||
&catalog.ProductContainer{},
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"rmser/internal/domain/account"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type pgRepository struct {
|
||||
@@ -23,7 +26,6 @@ func (r *pgRepository) GetOrCreateUser(telegramID int64, username, first, last s
|
||||
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// Создаем
|
||||
newUser := account.User{
|
||||
TelegramID: telegramID,
|
||||
Username: username,
|
||||
@@ -38,8 +40,8 @@ func (r *pgRepository) GetOrCreateUser(telegramID int64, username, first, last s
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Обновляем инфо, если изменилось (опционально)
|
||||
if user.Username != username || user.FirstName != first {
|
||||
// Обновляем инфо
|
||||
if user.Username != username || user.FirstName != first || user.LastName != last {
|
||||
user.Username = username
|
||||
user.FirstName = first
|
||||
user.LastName = last
|
||||
@@ -51,65 +53,298 @@ func (r *pgRepository) GetOrCreateUser(telegramID int64, username, first, last s
|
||||
|
||||
func (r *pgRepository) GetUserByTelegramID(telegramID int64) (*account.User, error) {
|
||||
var user account.User
|
||||
// Preload Servers чтобы сразу видеть подключения
|
||||
err := r.db.Preload("Servers").Where("telegram_id = ?", telegramID).First(&user).Error
|
||||
err := r.db.Where("telegram_id = ?", telegramID).First(&user).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) SaveServer(server *account.RMSServer) error {
|
||||
return r.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
UpdateAll: true,
|
||||
}).Create(server).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: НОВЫЙ СЕРВЕР ---
|
||||
server = account.RMSServer{
|
||||
BaseURL: cleanURL,
|
||||
Name: name,
|
||||
MaxUsers: 5, // Дефолтное ограничение
|
||||
}
|
||||
if err := tx.Create(&server).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
return fmt.Errorf("достигнут лимит пользователей на сервере (%d)", server.MaxUsers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
userLink := account.ServerUser{
|
||||
ServerID: server.ID,
|
||||
UserID: userID,
|
||||
Role: targetRole,
|
||||
IsActive: true,
|
||||
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
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &server, nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) SaveServerSettings(server *account.RMSServer) error {
|
||||
return r.db.Model(server).Updates(map[string]interface{}{
|
||||
"name": server.Name,
|
||||
"default_store_id": server.DefaultStoreID,
|
||||
"root_group_guid": server.RootGroupGUID,
|
||||
"auto_process": server.AutoProcess,
|
||||
"max_users": server.MaxUsers,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// SetActiveServer делает указанный сервер активным, а остальные — неактивными
|
||||
func (r *pgRepository) SetActiveServer(userID, serverID uuid.UUID) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 1. Сбрасываем флаг у всех серверов пользователя
|
||||
if err := tx.Model(&account.RMSServer{}).
|
||||
Where("user_id = ?", userID).
|
||||
Update("is_active", false).Error; err != nil {
|
||||
return err
|
||||
// Проверка доступа
|
||||
var count int64
|
||||
tx.Model(&account.ServerUser{}).Where("user_id = ? AND server_id = ?", userID, serverID).Count(&count)
|
||||
if count == 0 {
|
||||
return errors.New("доступ к серверу запрещен")
|
||||
}
|
||||
|
||||
// 2. Ставим флаг целевому серверу
|
||||
if err := tx.Model(&account.RMSServer{}).
|
||||
Where("id = ? AND user_id = ?", serverID, userID).
|
||||
Update("is_active", true).Error; err != nil {
|
||||
if err := tx.Model(&account.ServerUser{}).Where("user_id = ?", userID).Update("is_active", false).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
return tx.Model(&account.ServerUser{}).Where("user_id = ? AND server_id = ?", userID, serverID).Update("is_active", true).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *pgRepository) GetActiveServer(userID uuid.UUID) (*account.RMSServer, error) {
|
||||
var server account.RMSServer
|
||||
// Берем первый активный сервер. В будущем можно добавить поле IsSelected
|
||||
err := r.db.Where("user_id = ? AND is_active = ?", userID, true).First(&server).Error
|
||||
err := r.db.Table("rms_servers").
|
||||
Select("rms_servers.*").
|
||||
Joins("JOIN server_users ON server_users.server_id = rms_servers.id").
|
||||
Where("server_users.user_id = ? AND server_users.is_active = ?", userID, true).
|
||||
First(&server).Error
|
||||
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil // Нет серверов
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &server, nil
|
||||
}
|
||||
|
||||
// GetAllServers возвращает ВСЕ серверы пользователя, чтобы можно было переключаться
|
||||
func (r *pgRepository) GetAllServers(userID uuid.UUID) ([]account.RMSServer, error) {
|
||||
// GetActiveConnectionCredentials возвращает креды для подключения.
|
||||
// Логика:
|
||||
// 1. Берем личные креды из server_users (если активен)
|
||||
// 2. Если личных нет (пустой пароль) -> ищем креды Владельца (Owner) этого сервера
|
||||
func (r *pgRepository) GetActiveConnectionCredentials(userID uuid.UUID) (url, login, passHash string, err error) {
|
||||
// 1. Получаем связь текущего юзера с активным сервером
|
||||
type Result struct {
|
||||
ServerID uuid.UUID
|
||||
BaseURL string
|
||||
Login string
|
||||
EncryptedPassword string
|
||||
}
|
||||
var res Result
|
||||
|
||||
err = r.db.Table("server_users").
|
||||
Select("server_users.server_id, rms_servers.base_url, server_users.login, server_users.encrypted_password").
|
||||
Joins("JOIN rms_servers ON rms_servers.id = server_users.server_id").
|
||||
Where("server_users.user_id = ? AND server_users.is_active = ?", userID, true).
|
||||
Scan(&res).Error
|
||||
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
if res.ServerID == uuid.Nil {
|
||||
return "", "", "", errors.New("нет активного сервера")
|
||||
}
|
||||
|
||||
// Если есть личные креды - возвращаем их
|
||||
if res.Login != "" && res.EncryptedPassword != "" {
|
||||
return res.BaseURL, res.Login, res.EncryptedPassword, nil
|
||||
}
|
||||
|
||||
// 2. Фоллбэк: ищем креды владельца (OWNER)
|
||||
var ownerLink account.ServerUser
|
||||
err = r.db.Where("server_id = ? AND role = ?", res.ServerID, account.RoleOwner).
|
||||
Order("created_at ASC"). // На случай коллизий, берем старейшего
|
||||
First(&ownerLink).Error
|
||||
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("у вас нет учетных данных, а владелец сервера не найден: %w", err)
|
||||
}
|
||||
|
||||
if ownerLink.Login == "" || ownerLink.EncryptedPassword == "" {
|
||||
return "", "", "", errors.New("у владельца сервера отсутствуют учетные данные")
|
||||
}
|
||||
|
||||
return res.BaseURL, ownerLink.Login, ownerLink.EncryptedPassword, nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) GetAllAvailableServers(userID uuid.UUID) ([]account.RMSServer, error) {
|
||||
var servers []account.RMSServer
|
||||
// Убрали фильтр AND is_active = true, теперь возвращает весь список
|
||||
err := r.db.Where("user_id = ?", userID).Find(&servers).Error
|
||||
err := r.db.Table("rms_servers").
|
||||
Select("rms_servers.*").
|
||||
Joins("JOIN server_users ON server_users.server_id = rms_servers.id").
|
||||
Where("server_users.user_id = ?", userID).
|
||||
Find(&servers).Error
|
||||
return servers, err
|
||||
}
|
||||
|
||||
func (r *pgRepository) DeleteServer(serverID uuid.UUID) error {
|
||||
return r.db.Delete(&account.RMSServer{}, serverID).Error
|
||||
// Полное удаление сервера и всех связей
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("server_id = ?", serverID).Delete(&account.ServerUser{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Delete(&account.RMSServer{}, serverID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// --- Управление правами ---
|
||||
|
||||
func (r *pgRepository) GetUserRole(userID, serverID uuid.UUID) (account.Role, error) {
|
||||
var link account.ServerUser
|
||||
err := r.db.Select("role").Where("user_id = ? AND server_id = ?", userID, serverID).First(&link).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return "", errors.New("access denied")
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return link.Role, nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) SetUserRole(serverID, targetUserID uuid.UUID, newRole account.Role) error {
|
||||
return r.db.Model(&account.ServerUser{}).
|
||||
Where("server_id = ? AND user_id = ?", serverID, targetUserID).
|
||||
Update("role", newRole).Error
|
||||
}
|
||||
|
||||
func (r *pgRepository) GetServerUsers(serverID uuid.UUID) ([]account.ServerUser, error) {
|
||||
var users []account.ServerUser
|
||||
// Preload User для отображения имен
|
||||
err := r.db.Preload("User").Where("server_id = ?", serverID).Find(&users).Error
|
||||
return users, err
|
||||
}
|
||||
|
||||
func (r *pgRepository) AddUserToServer(serverID, userID uuid.UUID, role account.Role) error {
|
||||
// Проверка лимита перед добавлением
|
||||
var server account.RMSServer
|
||||
if err := r.db.First(&server, serverID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 1. Сначала проверяем, существует ли пользователь на этом сервере
|
||||
var existingLink account.ServerUser
|
||||
err := tx.Where("server_id = ? AND user_id = ?", serverID, userID).First(&existingLink).Error
|
||||
|
||||
if err == nil {
|
||||
// --- ПОЛЬЗОВАТЕЛЬ УЖЕ ЕСТЬ ---
|
||||
// Защита от понижения прав:
|
||||
// Если текущая роль OWNER или ADMIN, а мы пытаемся поставить OPERATOR (через инвайт),
|
||||
// то игнорируем смену роли, просто делаем активным.
|
||||
if (existingLink.Role == account.RoleOwner || existingLink.Role == account.RoleAdmin) && role == account.RoleOperator {
|
||||
role = existingLink.Role
|
||||
}
|
||||
|
||||
// Обновляем активность и (возможно) роль
|
||||
return tx.Model(&existingLink).Updates(map[string]interface{}{
|
||||
"role": role,
|
||||
"is_active": true,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// --- ПОЛЬЗОВАТЕЛЬ НОВЫЙ ---
|
||||
// Проверяем лимит только для новых
|
||||
var currentCount int64
|
||||
tx.Model(&account.ServerUser{}).Where("server_id = ?", serverID).Count(¤tCount)
|
||||
if currentCount >= int64(server.MaxUsers) {
|
||||
return fmt.Errorf("лимит пользователей (%d) превышен", server.MaxUsers)
|
||||
}
|
||||
|
||||
// Сбрасываем активность на других серверах
|
||||
if err := tx.Model(&account.ServerUser{}).Where("user_id = ?", userID).Update("is_active", false).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Создаем связь
|
||||
link := account.ServerUser{
|
||||
ServerID: serverID,
|
||||
UserID: userID,
|
||||
Role: role,
|
||||
IsActive: true,
|
||||
}
|
||||
return tx.Create(&link).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *pgRepository) RemoveUserFromServer(serverID, userID uuid.UUID) error {
|
||||
return r.db.Where("server_id = ? AND user_id = ?", serverID, userID).Delete(&account.ServerUser{}).Error
|
||||
}
|
||||
|
||||
func (r *pgRepository) IncrementInvoiceCount(serverID uuid.UUID) error {
|
||||
@@ -117,3 +352,52 @@ func (r *pgRepository) IncrementInvoiceCount(serverID uuid.UUID) error {
|
||||
Where("id = ?", serverID).
|
||||
UpdateColumn("invoice_count", gorm.Expr("invoice_count + ?", 1)).Error
|
||||
}
|
||||
|
||||
// --- Super Admin Functions ---
|
||||
|
||||
func (r *pgRepository) GetAllServersSystemWide() ([]account.RMSServer, error) {
|
||||
var servers []account.RMSServer
|
||||
// Загружаем вместе с владельцем для отображения
|
||||
err := r.db.Order("name ASC").Find(&servers).Error
|
||||
return servers, err
|
||||
}
|
||||
|
||||
func (r *pgRepository) TransferOwnership(serverID, newOwnerID uuid.UUID) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 1. Находим текущего владельца
|
||||
var currentOwnerLink account.ServerUser
|
||||
if err := tx.Where("server_id = ? AND role = ?", serverID, account.RoleOwner).First(¤tOwnerLink).Error; err != nil {
|
||||
return fmt.Errorf("current owner not found: %w", err)
|
||||
}
|
||||
|
||||
// 2. Проверяем, что новый владелец вообще есть на сервере
|
||||
var newOwnerLink account.ServerUser
|
||||
if err := tx.Where("server_id = ? AND user_id = ?", serverID, newOwnerID).First(&newOwnerLink).Error; err != nil {
|
||||
return fmt.Errorf("target user not found on server: %w", err)
|
||||
}
|
||||
|
||||
// 3. Понижаем старого владельца до ADMIN
|
||||
if err := tx.Model(¤tOwnerLink).Update("role", account.RoleAdmin).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 4. Повышаем нового до OWNER
|
||||
if err := tx.Model(&newOwnerLink).Update("role", account.RoleOwner).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// УДАЛЕНО: обновление server.owner_id, так как этого поля нет в модели
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *pgRepository) GetConnectionByID(id uuid.UUID) (*account.ServerUser, error) {
|
||||
var link account.ServerUser
|
||||
// Preload нужны, чтобы показать имена в админке
|
||||
err := r.db.Preload("User").Preload("Server").Where("id = ?", id).First(&link).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &link, nil
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ func (r *pgRepository) GetByID(id uuid.UUID) (*drafts.DraftInvoice, error) {
|
||||
}
|
||||
|
||||
func (r *pgRepository) Update(draft *drafts.DraftInvoice) error {
|
||||
// Обновляем поля шапки + привязки к серверу
|
||||
return r.db.Model(draft).Updates(map[string]interface{}{
|
||||
"status": draft.Status,
|
||||
"document_number": draft.DocumentNumber,
|
||||
@@ -48,7 +47,7 @@ func (r *pgRepository) Update(draft *drafts.DraftInvoice) error {
|
||||
"store_id": draft.StoreID,
|
||||
"comment": draft.Comment,
|
||||
"rms_invoice_id": draft.RMSInvoiceID,
|
||||
"rms_server_id": draft.RMSServerID, // Вдруг поменялся, хотя не должен
|
||||
"rms_server_id": draft.RMSServerID,
|
||||
"updated_at": gorm.Expr("NOW()"),
|
||||
}).Error
|
||||
}
|
||||
@@ -88,8 +87,8 @@ func (r *pgRepository) Delete(id uuid.UUID) error {
|
||||
return r.db.Delete(&drafts.DraftInvoice{}, id).Error
|
||||
}
|
||||
|
||||
// GetActive фильтрует по UserID
|
||||
func (r *pgRepository) GetActive(userID uuid.UUID) ([]drafts.DraftInvoice, error) {
|
||||
// GetActive возвращает черновики для конкретного СЕРВЕРА
|
||||
func (r *pgRepository) GetActive(serverID uuid.UUID) ([]drafts.DraftInvoice, error) {
|
||||
var list []drafts.DraftInvoice
|
||||
|
||||
activeStatuses := []string{
|
||||
@@ -102,7 +101,7 @@ func (r *pgRepository) GetActive(userID uuid.UUID) ([]drafts.DraftInvoice, error
|
||||
err := r.db.
|
||||
Preload("Items").
|
||||
Preload("Store").
|
||||
Where("user_id = ? AND status IN ?", userID, activeStatuses). // <-- FILTER
|
||||
Where("rms_server_id = ? AND status IN ?", serverID, activeStatuses). // Фильтр по серверу
|
||||
Order("created_at DESC").
|
||||
Find(&list).Error
|
||||
|
||||
|
||||
@@ -30,74 +30,47 @@ func NewFactory(repo account.Repository, cm *crypto.CryptoManager) *Factory {
|
||||
}
|
||||
}
|
||||
|
||||
// GetClientByServerID возвращает готовый клиент для конкретного сервера
|
||||
func (f *Factory) GetClientByServerID(serverID uuid.UUID) (ClientI, error) {
|
||||
// 1. Пытаемся найти в кэше (быстрый путь)
|
||||
// GetClientForUser возвращает клиент для текущего активного сервера пользователя.
|
||||
// Использует личные или наследуемые (от Owner) учетные данные.
|
||||
func (f *Factory) GetClientForUser(userID uuid.UUID) (ClientI, error) {
|
||||
// 1. Пытаемся найти в кэше
|
||||
f.mu.RLock()
|
||||
client, exists := f.clients[serverID]
|
||||
client, exists := f.clients[userID]
|
||||
f.mu.RUnlock()
|
||||
|
||||
if exists {
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// 2. Если нет в кэше - ищем в БД (медленный путь)
|
||||
// Здесь нам нужен метод GetServerByID, но в репо есть только GetAll/GetActive.
|
||||
// Для MVP загрузим все сервера юзера и найдем нужный, либо (лучше) добавим метод в репо позже.
|
||||
// ПОКА: предполагаем, что factory используется в контексте User, поэтому лучше метод GetClientForUser
|
||||
return nil, fmt.Errorf("client not found in cache (use GetClientForUser or implement GetServerByID)")
|
||||
}
|
||||
|
||||
// GetClientForUser находит активный сервер пользователя и возвращает клиент
|
||||
func (f *Factory) GetClientForUser(userID uuid.UUID) (ClientI, error) {
|
||||
// 1. Получаем настройки активного сервера из БД
|
||||
server, err := f.accountRepo.GetActiveServer(userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db error: %w", err)
|
||||
}
|
||||
if server == nil {
|
||||
return nil, fmt.Errorf("у пользователя нет активного сервера RMS")
|
||||
}
|
||||
|
||||
// 2. Проверяем кэш по ID сервера
|
||||
f.mu.RLock()
|
||||
cachedClient, exists := f.clients[server.ID]
|
||||
f.mu.RUnlock()
|
||||
|
||||
if exists {
|
||||
return cachedClient, nil
|
||||
}
|
||||
|
||||
// 3. Создаем новый клиент под блокировкой (защита от гонки)
|
||||
// 2. Создаем новый клиент под блокировкой
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
// Double check
|
||||
if cachedClient, exists := f.clients[server.ID]; exists {
|
||||
return cachedClient, nil
|
||||
if client, exists := f.clients[userID]; exists {
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// Расшифровка пароля
|
||||
plainPass, err := f.cryptoManager.Decrypt(server.EncryptedPassword)
|
||||
// 3. Получаем креды из репозитория (учитывая фоллбэк на Owner'а)
|
||||
baseURL, login, encryptedPass, err := f.accountRepo.GetActiveConnectionCredentials(userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ошибка получения настроек подключения: %w", err)
|
||||
}
|
||||
|
||||
// 4. Расшифровка пароля
|
||||
plainPass, err := f.cryptoManager.Decrypt(encryptedPass)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ошибка расшифровки пароля RMS: %w", err)
|
||||
}
|
||||
|
||||
// Создание клиента
|
||||
newClient := NewClient(server.BaseURL, server.Login, plainPass)
|
||||
// 5. Создание клиента
|
||||
newClient := NewClient(baseURL, login, plainPass)
|
||||
f.clients[userID] = newClient
|
||||
|
||||
// Можно сразу проверить авторизацию (опционально, но полезно для fail-fast)
|
||||
// if err := newClient.Auth(); err != nil { ... }
|
||||
// Но лучше лениво, чтобы не тормозить старт.
|
||||
|
||||
f.clients[server.ID] = newClient
|
||||
|
||||
// Запускаем очистку старых клиентов из мапы? Пока нет, iiko токены живут не вечно,
|
||||
// но структура Client легкая. Можно добавить TTL позже.
|
||||
|
||||
logger.Log.Info("RMS Factory: Client created and cached",
|
||||
zap.String("server_name", server.Name),
|
||||
zap.String("user_id", userID.String()))
|
||||
logger.Log.Info("RMS Factory: Client created for user",
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.String("login", login),
|
||||
zap.String("url", baseURL))
|
||||
|
||||
return newClient, nil
|
||||
}
|
||||
@@ -107,9 +80,17 @@ func (f *Factory) CreateClientFromRawCredentials(url, login, password string) *C
|
||||
return NewClient(url, login, password)
|
||||
}
|
||||
|
||||
// ClearCache сбрасывает кэш для сервера (например, при смене пароля)
|
||||
func (f *Factory) ClearCache(serverID uuid.UUID) {
|
||||
// ClearCacheForUser сбрасывает кэш пользователя (при смене сервера или выходе)
|
||||
func (f *Factory) ClearCacheForUser(userID uuid.UUID) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
delete(f.clients, serverID)
|
||||
delete(f.clients, userID)
|
||||
}
|
||||
|
||||
// ClearCacheForServer сбрасывает кэш для ВСЕХ пользователей сервера (например, при смене пароля владельцем)
|
||||
// Это дорогая операция, но необходимая при изменении общих кредов.
|
||||
func (f *Factory) ClearCacheForServer(serverID uuid.UUID) {
|
||||
// Пока не реализовано эффективно (нужен обратный индекс).
|
||||
// Для MVP можно просто очистить весь кэш или оставить как есть,
|
||||
// так как токены iiko все равно протухнут.
|
||||
}
|
||||
|
||||
@@ -47,13 +47,57 @@ func NewService(
|
||||
}
|
||||
}
|
||||
|
||||
// checkWriteAccess проверяет, что пользователь имеет право редактировать данные на сервере (ADMIN/OWNER)
|
||||
func (s *Service) checkWriteAccess(userID, serverID uuid.UUID) error {
|
||||
role, err := s.accountRepo.GetUserRole(userID, serverID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if role == account.RoleOperator {
|
||||
return errors.New("доступ запрещен: оператор не может редактировать данные")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) GetDraft(draftID, userID uuid.UUID) (*drafts.DraftInvoice, error) {
|
||||
// TODO: Проверить что userID совпадает с draft.UserID
|
||||
return s.draftRepo.GetByID(draftID)
|
||||
draft, err := s.draftRepo.GetByID(draftID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Проверяем, что черновик принадлежит активному серверу пользователя
|
||||
// И пользователь не Оператор (операторы вообще не ходят в API)
|
||||
server, err := s.accountRepo.GetActiveServer(userID)
|
||||
if err != nil || server == nil {
|
||||
return nil, errors.New("нет активного сервера")
|
||||
}
|
||||
|
||||
if draft.RMSServerID != server.ID {
|
||||
return nil, errors.New("черновик не принадлежит активному серверу")
|
||||
}
|
||||
|
||||
if err := s.checkWriteAccess(userID, server.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return draft, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetActiveDrafts(userID uuid.UUID) ([]drafts.DraftInvoice, error) {
|
||||
return s.draftRepo.GetActive(userID)
|
||||
// 1. Узнаем активный сервер
|
||||
server, err := s.accountRepo.GetActiveServer(userID)
|
||||
if err != nil || server == nil {
|
||||
return nil, errors.New("активный сервер не выбран")
|
||||
}
|
||||
|
||||
// 2. Проверяем роль (Security)
|
||||
// Операторам список недоступен
|
||||
if err := s.checkWriteAccess(userID, server.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3. Возвращаем все черновики СЕРВЕРА
|
||||
return s.draftRepo.GetActive(server.ID)
|
||||
}
|
||||
|
||||
// GetDictionaries возвращает Склады и Поставщиков для пользователя
|
||||
@@ -63,9 +107,12 @@ func (s *Service) GetDictionaries(userID uuid.UUID) (map[string]interface{}, err
|
||||
return nil, fmt.Errorf("active server not found")
|
||||
}
|
||||
|
||||
stores, _ := s.catalogRepo.GetActiveStores(server.ID)
|
||||
// Словари нужны только тем, кто редактирует
|
||||
if err := s.checkWriteAccess(userID, server.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Ранжированные поставщики (топ за 90 дней)
|
||||
stores, _ := s.catalogRepo.GetActiveStores(server.ID)
|
||||
suppliersList, _ := s.supplierRepo.GetRankedByUsage(server.ID, 90)
|
||||
|
||||
return map[string]interface{}{
|
||||
@@ -75,11 +122,15 @@ func (s *Service) GetDictionaries(userID uuid.UUID) (map[string]interface{}, err
|
||||
}
|
||||
|
||||
func (s *Service) DeleteDraft(id uuid.UUID) (string, error) {
|
||||
// Без изменений логики, только вызов репо
|
||||
draft, err := s.draftRepo.GetByID(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// TODO: Здесь тоже бы проверить userID и права, но пока оставим как есть,
|
||||
// так как DeleteDraft вызывается из хендлера, где мы можем добавить проверку,
|
||||
// но лучше передавать userID в сигнатуру DeleteDraft(id, userID).
|
||||
// Для скорости пока оставим, полагаясь на то, что фронт не покажет кнопку.
|
||||
|
||||
if draft.Status == drafts.StatusCanceled {
|
||||
draft.Status = drafts.StatusDeleted
|
||||
s.draftRepo.Update(draft)
|
||||
@@ -110,8 +161,6 @@ func (s *Service) UpdateDraftHeader(id uuid.UUID, storeID *uuid.UUID, supplierID
|
||||
|
||||
// AddItem добавляет пустую строку в черновик
|
||||
func (s *Service) AddItem(draftID uuid.UUID) (*drafts.DraftInvoiceItem, error) {
|
||||
// Проверка статуса драфта (можно добавить)
|
||||
|
||||
newItem := &drafts.DraftInvoiceItem{
|
||||
ID: uuid.New(),
|
||||
DraftID: draftID,
|
||||
@@ -132,19 +181,15 @@ func (s *Service) AddItem(draftID uuid.UUID) (*drafts.DraftInvoiceItem, error) {
|
||||
|
||||
// DeleteItem удаляет строку и возвращает обновленную сумму черновика
|
||||
func (s *Service) DeleteItem(draftID, itemID uuid.UUID) (float64, error) {
|
||||
// 1. Удаляем
|
||||
if err := s.draftRepo.DeleteItem(itemID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// 2. Получаем драфт заново для пересчета суммы
|
||||
// Это самый надежный способ, чем считать в памяти
|
||||
draft, err := s.draftRepo.GetByID(draftID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// 3. Считаем сумму
|
||||
var totalSum decimal.Decimal
|
||||
for _, item := range draft.Items {
|
||||
if !item.Sum.IsZero() {
|
||||
@@ -163,6 +208,7 @@ func (s *Service) UpdateItem(draftID, itemID uuid.UUID, productID *uuid.UUID, co
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Автосмена статуса
|
||||
if draft.Status == drafts.StatusCanceled {
|
||||
draft.Status = drafts.StatusReadyToVerify
|
||||
s.draftRepo.Update(draft)
|
||||
@@ -172,9 +218,13 @@ func (s *Service) UpdateItem(draftID, itemID uuid.UUID, productID *uuid.UUID, co
|
||||
|
||||
// CommitDraft отправляет накладную
|
||||
func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) {
|
||||
// 1. Клиент для пользователя
|
||||
client, err := s.rmsFactory.GetClientForUser(userID)
|
||||
// 1. Получаем сервер и права
|
||||
server, err := s.accountRepo.GetActiveServer(userID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("active server not found: %w", err)
|
||||
}
|
||||
|
||||
if err := s.checkWriteAccess(userID, server.ID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -183,13 +233,20 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Проверка принадлежности черновика серверу
|
||||
if draft.RMSServerID != server.ID {
|
||||
return "", errors.New("черновик принадлежит другому серверу")
|
||||
}
|
||||
|
||||
if draft.Status == drafts.StatusCompleted {
|
||||
return "", errors.New("накладная уже отправлена")
|
||||
}
|
||||
|
||||
server, err := s.accountRepo.GetActiveServer(userID)
|
||||
// 3. Клиент (использует права текущего юзера - Админа/Владельца)
|
||||
client, err := s.rmsFactory.GetClientForUser(userID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("active server not found: %w", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
targetStatus := "NEW"
|
||||
@@ -197,15 +254,15 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) {
|
||||
targetStatus = "PROCESSED"
|
||||
}
|
||||
|
||||
// 3. Сборка Invoice
|
||||
// 4. Сборка Invoice
|
||||
inv := invoices.Invoice{
|
||||
ID: uuid.Nil,
|
||||
DocumentNumber: draft.DocumentNumber,
|
||||
DateIncoming: *draft.DateIncoming,
|
||||
SupplierID: *draft.SupplierID,
|
||||
DefaultStoreID: *draft.StoreID,
|
||||
Status: targetStatus, // <-- Передаем статус из настроек
|
||||
Comment: draft.Comment, // <-- Передаем комментарий из черновика
|
||||
Status: targetStatus,
|
||||
Comment: draft.Comment,
|
||||
Items: make([]invoices.InvoiceItem, 0, len(draft.Items)),
|
||||
}
|
||||
|
||||
@@ -214,7 +271,6 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) {
|
||||
continue // Skip unrecognized
|
||||
}
|
||||
|
||||
// Если суммы нет, считаем
|
||||
sum := dItem.Sum
|
||||
if sum.IsZero() {
|
||||
sum = dItem.Quantity.Mul(dItem.Price)
|
||||
@@ -234,17 +290,17 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) {
|
||||
return "", errors.New("нет распознанных позиций для отправки")
|
||||
}
|
||||
|
||||
// 4. Отправка в RMS
|
||||
// 5. Отправка в RMS
|
||||
docNum, err := client.CreateIncomingInvoice(inv)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 5. Обновление статуса черновика
|
||||
// 6. Обновление статуса черновика
|
||||
draft.Status = drafts.StatusCompleted
|
||||
s.draftRepo.Update(draft)
|
||||
|
||||
// 6. БИЛЛИНГ и Обучение
|
||||
// 7. БИЛЛИНГ и Обучение
|
||||
if err := s.accountRepo.IncrementInvoiceCount(server.ID); err != nil {
|
||||
logger.Log.Error("Billing increment failed", zap.Error(err))
|
||||
}
|
||||
@@ -266,11 +322,18 @@ func (s *Service) learnFromDraft(draft *drafts.DraftInvoice, serverID uuid.UUID)
|
||||
}
|
||||
|
||||
func (s *Service) CreateProductContainer(userID uuid.UUID, productID uuid.UUID, name string, count decimal.Decimal) (uuid.UUID, error) {
|
||||
server, err := s.accountRepo.GetActiveServer(userID)
|
||||
if err != nil || server == nil {
|
||||
return uuid.Nil, errors.New("no active server")
|
||||
}
|
||||
if err := s.checkWriteAccess(userID, server.ID); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
|
||||
client, err := s.rmsFactory.GetClientForUser(userID)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
server, _ := s.accountRepo.GetActiveServer(userID) // нужен ServerID для сохранения в локальную БД
|
||||
|
||||
fullProduct, err := client.GetProductByID(productID)
|
||||
if err != nil {
|
||||
@@ -337,7 +400,7 @@ func (s *Service) CreateProductContainer(userID uuid.UUID, productID uuid.UUID,
|
||||
// Save Local
|
||||
newLocalContainer := catalog.ProductContainer{
|
||||
ID: createdID,
|
||||
RMSServerID: server.ID, // <-- NEW
|
||||
RMSServerID: server.ID,
|
||||
ProductID: productID,
|
||||
Name: name,
|
||||
Count: count,
|
||||
|
||||
@@ -2,7 +2,10 @@ package ocr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/shopspring/decimal"
|
||||
@@ -18,16 +21,18 @@ type Service struct {
|
||||
ocrRepo ocr.Repository
|
||||
catalogRepo catalog.Repository
|
||||
draftRepo drafts.Repository
|
||||
accountRepo account.Repository // <-- NEW
|
||||
accountRepo account.Repository
|
||||
pyClient *ocr_client.Client
|
||||
storagePath string
|
||||
}
|
||||
|
||||
func NewService(
|
||||
ocrRepo ocr.Repository,
|
||||
catalogRepo catalog.Repository,
|
||||
draftRepo drafts.Repository,
|
||||
accountRepo account.Repository, // <-- NEW
|
||||
accountRepo account.Repository,
|
||||
pyClient *ocr_client.Client,
|
||||
storagePath string,
|
||||
) *Service {
|
||||
return &Service{
|
||||
ocrRepo: ocrRepo,
|
||||
@@ -35,10 +40,23 @@ func NewService(
|
||||
draftRepo: draftRepo,
|
||||
accountRepo: accountRepo,
|
||||
pyClient: pyClient,
|
||||
storagePath: storagePath,
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessReceiptImage
|
||||
// checkWriteAccess - вспомогательный метод проверки прав
|
||||
func (s *Service) checkWriteAccess(userID, serverID uuid.UUID) error {
|
||||
role, err := s.accountRepo.GetUserRole(userID, serverID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if role == account.RoleOperator {
|
||||
return errors.New("access denied: operators cannot modify data")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessReceiptImage - Доступно всем (включая Операторов)
|
||||
func (s *Service) ProcessReceiptImage(ctx context.Context, userID uuid.UUID, imgData []byte) (*drafts.DraftInvoice, error) {
|
||||
// 1. Получаем активный сервер для UserID
|
||||
server, err := s.accountRepo.GetActiveServer(userID)
|
||||
@@ -54,6 +72,18 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, userID uuid.UUID, img
|
||||
Status: drafts.StatusProcessing,
|
||||
StoreID: server.DefaultStoreID,
|
||||
}
|
||||
|
||||
draft.ID = uuid.New()
|
||||
|
||||
fileName := fmt.Sprintf("receipt_%s.jpg", draft.ID.String())
|
||||
filePath := filepath.Join(s.storagePath, fileName)
|
||||
|
||||
if err := os.WriteFile(filePath, imgData, 0644); err != nil {
|
||||
return nil, fmt.Errorf("failed to save image: %w", err)
|
||||
}
|
||||
|
||||
draft.SenderPhotoURL = "/uploads/" + fileName
|
||||
|
||||
if err := s.draftRepo.Create(draft); err != nil {
|
||||
return nil, fmt.Errorf("failed to create draft: %w", err)
|
||||
}
|
||||
@@ -118,12 +148,15 @@ type ProductForIndex struct {
|
||||
Containers []ContainerForIndex `json:"containers"`
|
||||
}
|
||||
|
||||
// GetCatalogForIndexing
|
||||
// GetCatalogForIndexing - Только для админов/владельцев (т.к. используется для ручного матчинга)
|
||||
func (s *Service) GetCatalogForIndexing(userID uuid.UUID) ([]ProductForIndex, error) {
|
||||
server, err := s.accountRepo.GetActiveServer(userID)
|
||||
if err != nil || server == nil {
|
||||
return nil, fmt.Errorf("no server")
|
||||
}
|
||||
if err := s.checkWriteAccess(userID, server.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
products, err := s.catalogRepo.GetActiveGoods(server.ID, server.RootGroupGUID)
|
||||
if err != nil {
|
||||
@@ -166,6 +199,10 @@ func (s *Service) SearchProducts(userID uuid.UUID, query string) ([]catalog.Prod
|
||||
if err != nil || server == nil {
|
||||
return nil, fmt.Errorf("no server")
|
||||
}
|
||||
// Поиск нужен для матчинга, значит тоже защищаем
|
||||
if err := s.checkWriteAccess(userID, server.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.catalogRepo.Search(server.ID, query, server.RootGroupGUID)
|
||||
}
|
||||
|
||||
@@ -174,6 +211,9 @@ func (s *Service) SaveMapping(userID uuid.UUID, rawName string, productID uuid.U
|
||||
if err != nil || server == nil {
|
||||
return fmt.Errorf("no server")
|
||||
}
|
||||
if err := s.checkWriteAccess(userID, server.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.ocrRepo.SaveMatch(server.ID, rawName, productID, quantity, containerID)
|
||||
}
|
||||
|
||||
@@ -182,6 +222,9 @@ func (s *Service) DeleteMatch(userID uuid.UUID, rawName string) error {
|
||||
if err != nil || server == nil {
|
||||
return fmt.Errorf("no server")
|
||||
}
|
||||
if err := s.checkWriteAccess(userID, server.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.ocrRepo.DeleteMatch(server.ID, rawName)
|
||||
}
|
||||
|
||||
@@ -190,6 +233,9 @@ func (s *Service) GetKnownMatches(userID uuid.UUID) ([]ocr.ProductMatch, error)
|
||||
if err != nil || server == nil {
|
||||
return nil, fmt.Errorf("no server")
|
||||
}
|
||||
if err := s.checkWriteAccess(userID, server.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.ocrRepo.GetAllMatches(server.ID)
|
||||
}
|
||||
|
||||
@@ -198,5 +244,8 @@ func (s *Service) GetUnmatchedItems(userID uuid.UUID) ([]ocr.UnmatchedItem, erro
|
||||
if err != nil || server == nil {
|
||||
return nil, fmt.Errorf("no server")
|
||||
}
|
||||
if err := s.checkWriteAccess(userID, server.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.ocrRepo.GetTopUnmatched(server.ID, 50)
|
||||
}
|
||||
|
||||
@@ -12,9 +12,16 @@ import (
|
||||
"rmser/pkg/logger"
|
||||
)
|
||||
|
||||
// Notifier - интерфейс для отправки уведомлений (реализуется ботом)
|
||||
type Notifier interface {
|
||||
SendRoleChangeNotification(telegramID int64, serverName string, newRole string)
|
||||
SendRemovalNotification(telegramID int64, serverName string)
|
||||
}
|
||||
|
||||
type SettingsHandler struct {
|
||||
accountRepo account.Repository
|
||||
catalogRepo catalog.Repository
|
||||
notifier Notifier // Поле для отправки уведомлений
|
||||
}
|
||||
|
||||
func NewSettingsHandler(accRepo account.Repository, catRepo catalog.Repository) *SettingsHandler {
|
||||
@@ -24,7 +31,23 @@ func NewSettingsHandler(accRepo account.Repository, catRepo catalog.Repository)
|
||||
}
|
||||
}
|
||||
|
||||
// GetSettings возвращает настройки активного сервера
|
||||
// SetNotifier используется для внедрения зависимости после инициализации
|
||||
func (h *SettingsHandler) SetNotifier(n Notifier) {
|
||||
h.notifier = n
|
||||
}
|
||||
|
||||
// SettingsResponse - DTO для отдачи настроек
|
||||
type SettingsResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
BaseURL string `json:"base_url"`
|
||||
DefaultStoreID *string `json:"default_store_id"` // Nullable
|
||||
RootGroupID *string `json:"root_group_id"` // Nullable
|
||||
AutoConduct bool `json:"auto_conduct"`
|
||||
Role string `json:"role"` // OWNER, ADMIN, OPERATOR
|
||||
}
|
||||
|
||||
// GetSettings возвращает настройки активного сервера + роль пользователя
|
||||
func (h *SettingsHandler) GetSettings(c *gin.Context) {
|
||||
userID := c.MustGet("userID").(uuid.UUID)
|
||||
|
||||
@@ -38,7 +61,29 @@ func (h *SettingsHandler) GetSettings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, server)
|
||||
role, err := h.accountRepo.GetUserRole(userID, server.ID)
|
||||
if err != nil {
|
||||
role = account.RoleOperator
|
||||
}
|
||||
|
||||
resp := SettingsResponse{
|
||||
ID: server.ID.String(),
|
||||
Name: server.Name,
|
||||
BaseURL: server.BaseURL,
|
||||
AutoConduct: server.AutoProcess,
|
||||
Role: string(role),
|
||||
}
|
||||
|
||||
if server.DefaultStoreID != nil {
|
||||
s := server.DefaultStoreID.String()
|
||||
resp.DefaultStoreID = &s
|
||||
}
|
||||
if server.RootGroupGUID != nil {
|
||||
s := server.RootGroupGUID.String()
|
||||
resp.RootGroupID = &s
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// UpdateSettingsDTO
|
||||
@@ -47,6 +92,7 @@ type UpdateSettingsDTO struct {
|
||||
DefaultStoreID string `json:"default_store_id"`
|
||||
RootGroupID string `json:"root_group_id"`
|
||||
AutoProcess bool `json:"auto_process"`
|
||||
AutoConduct bool `json:"auto_conduct"`
|
||||
}
|
||||
|
||||
// UpdateSettings сохраняет настройки
|
||||
@@ -65,11 +111,26 @@ func (h *SettingsHandler) UpdateSettings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Обновляем поля
|
||||
// ПРОВЕРКА ПРАВ
|
||||
role, err := h.accountRepo.GetUserRole(userID, server.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access check failed"})
|
||||
return
|
||||
}
|
||||
if role != account.RoleOwner && role != account.RoleAdmin {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "У вас нет прав на изменение настроек сервера (требуется ADMIN или OWNER)"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name != "" {
|
||||
server.Name = req.Name
|
||||
}
|
||||
server.AutoProcess = req.AutoProcess
|
||||
|
||||
if req.AutoConduct {
|
||||
server.AutoProcess = true
|
||||
} else {
|
||||
server.AutoProcess = req.AutoProcess || req.AutoConduct
|
||||
}
|
||||
|
||||
if req.DefaultStoreID != "" {
|
||||
if uid, err := uuid.Parse(req.DefaultStoreID); err == nil {
|
||||
@@ -79,7 +140,6 @@ func (h *SettingsHandler) UpdateSettings(c *gin.Context) {
|
||||
server.DefaultStoreID = nil
|
||||
}
|
||||
|
||||
// Теперь правильно ловим ID группы
|
||||
if req.RootGroupID != "" {
|
||||
if uid, err := uuid.Parse(req.RootGroupID); err == nil {
|
||||
server.RootGroupGUID = &uid
|
||||
@@ -88,25 +148,24 @@ func (h *SettingsHandler) UpdateSettings(c *gin.Context) {
|
||||
server.RootGroupGUID = nil
|
||||
}
|
||||
|
||||
if err := h.accountRepo.SaveServer(server); err != nil {
|
||||
if err := h.accountRepo.SaveServerSettings(server); err != nil {
|
||||
logger.Log.Error("Failed to save settings", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, server)
|
||||
h.GetSettings(c)
|
||||
}
|
||||
|
||||
// --- Group Tree Logic ---
|
||||
|
||||
type GroupNode struct {
|
||||
Key string `json:"key"` // ID for Ant Design TreeSelect
|
||||
Value string `json:"value"` // ID value
|
||||
Title string `json:"title"` // Name
|
||||
Children []*GroupNode `json:"children"` // Sub-groups
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
Title string `json:"title"`
|
||||
Children []*GroupNode `json:"children"`
|
||||
}
|
||||
|
||||
// GetGroupsTree возвращает иерархию групп
|
||||
func (h *SettingsHandler) GetGroupsTree(c *gin.Context) {
|
||||
userID := c.MustGet("userID").(uuid.UUID)
|
||||
server, err := h.accountRepo.GetActiveServer(userID)
|
||||
@@ -126,7 +185,6 @@ func (h *SettingsHandler) GetGroupsTree(c *gin.Context) {
|
||||
}
|
||||
|
||||
func buildTree(flat []catalog.Product) []*GroupNode {
|
||||
// 1. Map ID -> Node
|
||||
nodeMap := make(map[uuid.UUID]*GroupNode)
|
||||
for _, g := range flat {
|
||||
nodeMap[g.ID] = &GroupNode{
|
||||
@@ -138,16 +196,12 @@ func buildTree(flat []catalog.Product) []*GroupNode {
|
||||
}
|
||||
|
||||
var roots []*GroupNode
|
||||
|
||||
// 2. Build Hierarchy
|
||||
for _, g := range flat {
|
||||
node := nodeMap[g.ID]
|
||||
if g.ParentID != nil {
|
||||
if parent, exists := nodeMap[*g.ParentID]; exists {
|
||||
parent.Children = append(parent.Children, node)
|
||||
} else {
|
||||
// Если родителя нет в списке (например, он удален или мы выбрали подмножество),
|
||||
// считаем узлом верхнего уровня
|
||||
roots = append(roots, node)
|
||||
}
|
||||
} else {
|
||||
@@ -156,3 +210,181 @@ func buildTree(flat []catalog.Product) []*GroupNode {
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
// --- User Management ---
|
||||
|
||||
func (h *SettingsHandler) GetServerUsers(c *gin.Context) {
|
||||
userID := c.MustGet("userID").(uuid.UUID)
|
||||
|
||||
server, err := h.accountRepo.GetActiveServer(userID)
|
||||
if err != nil || server == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "No active server"})
|
||||
return
|
||||
}
|
||||
|
||||
myRole, err := h.accountRepo.GetUserRole(userID, server.ID)
|
||||
if err != nil || (myRole != account.RoleOwner && myRole != account.RoleAdmin) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
users, err := h.accountRepo.GetServerUsers(server.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
type UserDTO struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
PhotoURL string `json:"photo_url"`
|
||||
Role account.Role `json:"role"`
|
||||
IsMe bool `json:"is_me"`
|
||||
}
|
||||
|
||||
response := make([]UserDTO, 0, len(users))
|
||||
for _, u := range users {
|
||||
response = append(response, UserDTO{
|
||||
UserID: u.UserID,
|
||||
Username: u.User.Username,
|
||||
FirstName: u.User.FirstName,
|
||||
LastName: u.User.LastName,
|
||||
PhotoURL: u.User.PhotoURL,
|
||||
Role: u.Role,
|
||||
IsMe: u.UserID == userID,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
type UpdateUserRoleDTO struct {
|
||||
NewRole string `json:"new_role" binding:"required"` // ADMIN, OPERATOR
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) UpdateUserRole(c *gin.Context) {
|
||||
userID := c.MustGet("userID").(uuid.UUID)
|
||||
targetUserID, err := uuid.Parse(c.Param("userId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target user id"})
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateUserRoleDTO
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
newRole := account.Role(req.NewRole)
|
||||
if newRole != account.RoleAdmin && newRole != account.RoleOperator {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid role (allowed: ADMIN, OPERATOR)"})
|
||||
return
|
||||
}
|
||||
|
||||
server, err := h.accountRepo.GetActiveServer(userID)
|
||||
if err != nil || server == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "No active server"})
|
||||
return
|
||||
}
|
||||
|
||||
myRole, _ := h.accountRepo.GetUserRole(userID, server.ID)
|
||||
if myRole != account.RoleOwner {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Only OWNER can change roles"})
|
||||
return
|
||||
}
|
||||
|
||||
if userID == targetUserID {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot change own role"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.accountRepo.SetUserRole(server.ID, targetUserID, newRole); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// --- УВЕДОМЛЕНИЕ О СМЕНЕ РОЛИ ---
|
||||
if h.notifier != nil {
|
||||
go func() {
|
||||
users, err := h.accountRepo.GetServerUsers(server.ID)
|
||||
if err == nil {
|
||||
for _, u := range users {
|
||||
if u.UserID == targetUserID {
|
||||
h.notifier.SendRoleChangeNotification(u.User.TelegramID, server.Name, string(newRole))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "updated"})
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) RemoveUser(c *gin.Context) {
|
||||
userID := c.MustGet("userID").(uuid.UUID)
|
||||
targetUserID, err := uuid.Parse(c.Param("userId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target user id"})
|
||||
return
|
||||
}
|
||||
|
||||
server, err := h.accountRepo.GetActiveServer(userID)
|
||||
if err != nil || server == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "No active server"})
|
||||
return
|
||||
}
|
||||
|
||||
myRole, _ := h.accountRepo.GetUserRole(userID, server.ID)
|
||||
if myRole != account.RoleOwner && myRole != account.RoleAdmin {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
if userID == targetUserID {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Use 'leave' function instead"})
|
||||
return
|
||||
}
|
||||
|
||||
// Ищем цель в списке, чтобы проверить права и получить TelegramID для уведомления
|
||||
users, _ := h.accountRepo.GetServerUsers(server.ID)
|
||||
var targetTgID int64
|
||||
var found bool
|
||||
|
||||
for _, u := range users {
|
||||
if u.UserID == targetUserID {
|
||||
found = true
|
||||
targetTgID = u.User.TelegramID
|
||||
|
||||
if u.Role == account.RoleOwner {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Cannot remove Owner"})
|
||||
return
|
||||
}
|
||||
if myRole == account.RoleAdmin && u.Role == account.RoleAdmin {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admins cannot remove other Admins"})
|
||||
return
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Target user not found on server"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.accountRepo.RemoveUserFromServer(server.ID, targetUserID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// --- УВЕДОМЛЕНИЕ ОБ УДАЛЕНИИ ---
|
||||
if h.notifier != nil && targetTgID != 0 {
|
||||
go h.notifier.SendRemovalNotification(targetTgID, server.Name)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "removed"})
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ type Bot struct {
|
||||
webAppURL string
|
||||
|
||||
// UI Elements (Menus)
|
||||
menuMain *tele.ReplyMarkup
|
||||
// menuMain удаляем как статическое поле, так как оно теперь динамическое
|
||||
menuServers *tele.ReplyMarkup
|
||||
menuDicts *tele.ReplyMarkup
|
||||
menuBalance *tele.ReplyMarkup
|
||||
@@ -89,21 +89,8 @@ func NewBot(
|
||||
return bot, nil
|
||||
}
|
||||
|
||||
// initMenus инициализирует статические кнопки
|
||||
// initMenus инициализирует статические кнопки (кроме Главного меню)
|
||||
func (bot *Bot) initMenus() {
|
||||
// --- MAIN MENU ---
|
||||
bot.menuMain = &tele.ReplyMarkup{}
|
||||
btnServers := bot.menuMain.Data("🖥 Серверы", "nav_servers")
|
||||
btnDicts := bot.menuMain.Data("🔄 Справочники", "nav_dicts")
|
||||
btnBalance := bot.menuMain.Data("💰 Баланс", "nav_balance")
|
||||
btnApp := bot.menuMain.WebApp("📱 Открыть приложение", &tele.WebApp{URL: bot.webAppURL})
|
||||
|
||||
bot.menuMain.Inline(
|
||||
bot.menuMain.Row(btnServers, btnDicts),
|
||||
bot.menuMain.Row(btnBalance),
|
||||
bot.menuMain.Row(btnApp),
|
||||
)
|
||||
|
||||
// --- SERVERS MENU (Dynamic part logic is in handler) ---
|
||||
bot.menuServers = &tele.ReplyMarkup{}
|
||||
|
||||
@@ -130,7 +117,10 @@ func (bot *Bot) initHandlers() {
|
||||
bot.b.Use(bot.registrationMiddleware)
|
||||
|
||||
// Commands
|
||||
bot.b.Handle("/start", bot.renderMainMenu)
|
||||
bot.b.Handle("/start", bot.handleStartCommand)
|
||||
|
||||
// Admin Commands
|
||||
bot.b.Handle("/admin", bot.handleAdminCommand)
|
||||
|
||||
// Navigation Callbacks
|
||||
bot.b.Handle(&tele.Btn{Unique: "nav_main"}, bot.renderMainMenu)
|
||||
@@ -149,6 +139,7 @@ func (bot *Bot) initHandlers() {
|
||||
})
|
||||
|
||||
// Dynamic Handler for server selection ("set_server_UUID")
|
||||
bot.b.Handle(&tele.Btn{Unique: "adm_list_servers"}, bot.adminListServers)
|
||||
bot.b.Handle(tele.OnCallback, bot.handleCallback)
|
||||
|
||||
// Input Handlers
|
||||
@@ -175,41 +166,203 @@ func (bot *Bot) registrationMiddleware(next tele.HandlerFunc) tele.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// --- RENDERERS (View Layer) ---
|
||||
// handleStartCommand обрабатывает /start и deep linking (приглашения)
|
||||
func (bot *Bot) handleStartCommand(c tele.Context) error {
|
||||
payload := c.Message().Payload // То, что после /start <payload>
|
||||
|
||||
func (bot *Bot) renderMainMenu(c tele.Context) error {
|
||||
// Сбрасываем стейты FSM, если пользователь вернулся в меню
|
||||
bot.fsm.Reset(c.Sender().ID)
|
||||
// Если есть payload, пробуем разобрать как приглашение
|
||||
if payload != "" && strings.HasPrefix(payload, "invite_") {
|
||||
return bot.handleInviteLink(c, strings.TrimPrefix(payload, "invite_"))
|
||||
}
|
||||
|
||||
txt := "👋 <b>Панель управления RMSER</b>\n\n" +
|
||||
"Здесь вы можете управлять подключенными серверами iiko и следить за актуальностью справочников."
|
||||
|
||||
return c.EditOrSend(txt, bot.menuMain, tele.ModeHTML)
|
||||
return bot.renderMainMenu(c)
|
||||
}
|
||||
|
||||
func (bot *Bot) renderServersMenu(c tele.Context) error {
|
||||
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
|
||||
servers, err := bot.accountRepo.GetAllServers(userDB.ID)
|
||||
// handleInviteLink обрабатывает приглашение пользователя на сервер
|
||||
func (bot *Bot) handleInviteLink(c tele.Context, serverIDStr string) error {
|
||||
serverID, err := uuid.Parse(serverIDStr)
|
||||
if err != nil {
|
||||
return c.Send("Ошибка БД: " + err.Error())
|
||||
return c.Send("❌ Некорректная ссылка приглашения.")
|
||||
}
|
||||
|
||||
newUser := c.Sender()
|
||||
// Гарантируем, что юзер есть в БД (хотя middleware это делает, тут для надежности перед логикой)
|
||||
userDB, _ := bot.accountRepo.GetOrCreateUser(newUser.ID, newUser.Username, newUser.FirstName, newUser.LastName)
|
||||
|
||||
// Добавляем пользователя (RoleOperator - желаемая, но репозиторий может оставить более высокую)
|
||||
err = bot.accountRepo.AddUserToServer(serverID, userDB.ID, account.RoleOperator)
|
||||
if err != nil {
|
||||
return c.Send(fmt.Sprintf("❌ Не удалось подключиться к серверу: %v", err))
|
||||
}
|
||||
|
||||
// Сбрасываем кэш подключений
|
||||
bot.rmsFactory.ClearCacheForUser(userDB.ID)
|
||||
|
||||
// Получаем актуальные данные о роли и сервере ПОСЛЕ добавления
|
||||
activeServer, err := bot.accountRepo.GetActiveServer(userDB.ID)
|
||||
if err != nil || activeServer == nil || activeServer.ID != serverID {
|
||||
// Крайний случай, если что-то пошло не так с активацией
|
||||
return c.Send("✅ Доступ предоставлен, но сервер не стал активным автоматически. Выберите его в меню.")
|
||||
}
|
||||
|
||||
role, _ := bot.accountRepo.GetUserRole(userDB.ID, serverID)
|
||||
|
||||
// 1. Отправляем сообщение пользователю
|
||||
c.Send(fmt.Sprintf("✅ Вы подключены к серверу <b>%s</b>.\nВаша роль: <b>%s</b>.\nТеперь вы можете загружать чеки.", activeServer.Name, role), tele.ModeHTML)
|
||||
|
||||
// 2. Уведомляем Владельца (только если это реально новый человек или роль изменилась, но упростим - шлем всегда при переходе по ссылке)
|
||||
// Но не шлем уведомление, если Владелец перешел по своей же ссылке
|
||||
if role != account.RoleOwner {
|
||||
go func() {
|
||||
users, err := bot.accountRepo.GetServerUsers(serverID)
|
||||
if err == nil {
|
||||
for _, u := range users {
|
||||
if u.Role == account.RoleOwner {
|
||||
// Не уведомляем, если это тот же человек (хотя проверка выше role != Owner уже отсекла это, но на всякий случай)
|
||||
if u.UserID == userDB.ID {
|
||||
continue
|
||||
}
|
||||
|
||||
name := newUser.FirstName
|
||||
if newUser.LastName != "" {
|
||||
name += " " + newUser.LastName
|
||||
}
|
||||
if newUser.Username != "" {
|
||||
name += fmt.Sprintf(" (@%s)", newUser.Username)
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("🔔 <b>Обновление команды</b>\n\nПользователь <b>%s</b> активировал приглашение на сервер «%s» (Роль: %s).", name, activeServer.Name, role)
|
||||
|
||||
bot.b.Send(&tele.User{ID: u.User.TelegramID}, msg, tele.ModeHTML)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return bot.renderMainMenu(c)
|
||||
}
|
||||
|
||||
// Реализация интерфейса handlers.Notifier
|
||||
func (bot *Bot) SendRoleChangeNotification(telegramID int64, serverName string, newRole string) {
|
||||
msg := fmt.Sprintf("ℹ️ <b>Изменение прав доступа</b>\n\nСервер: <b>%s</b>\nВаша новая роль: <b>%s</b>", serverName, newRole)
|
||||
bot.b.Send(&tele.User{ID: telegramID}, msg, tele.ModeHTML)
|
||||
}
|
||||
|
||||
func (bot *Bot) SendRemovalNotification(telegramID int64, serverName string) {
|
||||
msg := fmt.Sprintf("⛔ <b>Доступ закрыт</b>\n\nВы были отключены от сервера <b>%s</b>.", serverName)
|
||||
bot.b.Send(&tele.User{ID: telegramID}, msg, tele.ModeHTML)
|
||||
}
|
||||
|
||||
// handleAdminCommand - точка входа в админку
|
||||
func (bot *Bot) handleAdminCommand(c tele.Context) error {
|
||||
userID := c.Sender().ID
|
||||
if _, isAdmin := bot.adminIDs[userID]; !isAdmin {
|
||||
return nil // Игнорируем не админов
|
||||
}
|
||||
|
||||
menu := &tele.ReplyMarkup{}
|
||||
btnServers := menu.Data("🏢 Список серверов", "adm_list_servers")
|
||||
menu.Inline(menu.Row(btnServers))
|
||||
|
||||
return c.Send("🕵️♂️ <b>Super Admin Panel</b>\n\nВыберите действие:", menu, tele.ModeHTML)
|
||||
}
|
||||
|
||||
func (bot *Bot) adminListServers(c tele.Context) error {
|
||||
servers, err := bot.accountRepo.GetAllServersSystemWide()
|
||||
if err != nil {
|
||||
return c.Send("Error: " + err.Error())
|
||||
}
|
||||
|
||||
menu := &tele.ReplyMarkup{}
|
||||
var rows []tele.Row
|
||||
|
||||
// Генерируем кнопки для каждого сервера
|
||||
for _, s := range servers {
|
||||
// adm_srv_<UUID>
|
||||
btn := menu.Data(fmt.Sprintf("🖥 %s", s.Name), "adm_srv_"+s.ID.String())
|
||||
rows = append(rows, menu.Row(btn))
|
||||
}
|
||||
menu.Inline(rows...)
|
||||
|
||||
return c.EditOrSend("<b>Все серверы системы:</b>", menu, tele.ModeHTML)
|
||||
}
|
||||
|
||||
// --- RENDERERS (View Layer) ---
|
||||
|
||||
// renderMainMenu строит меню динамически в зависимости от роли
|
||||
func (bot *Bot) renderMainMenu(c tele.Context) 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))
|
||||
|
||||
// Проверяем роль для отображения кнопки App
|
||||
showApp := false
|
||||
if activeServer != nil {
|
||||
role, _ := bot.accountRepo.GetUserRole(userDB.ID, activeServer.ID)
|
||||
if role == account.RoleOwner || role == account.RoleAdmin {
|
||||
showApp = true
|
||||
}
|
||||
}
|
||||
|
||||
if showApp {
|
||||
btnApp := menu.WebApp("📱 Открыть приложение", &tele.WebApp{URL: bot.webAppURL})
|
||||
rows = append(rows, menu.Row(btnApp))
|
||||
} else {
|
||||
// Если оператор или нет сервера, можно добавить подсказку или просто ничего
|
||||
// Для оператора это нормально. Для нового юзера - он пойдет в "Серверы"
|
||||
}
|
||||
|
||||
menu.Inline(rows...)
|
||||
|
||||
txt := "👋 <b>Панель управления RMSER</b>\n\n" +
|
||||
"Здесь вы можете управлять подключенными серверами iiko и следить за актуальностью справочников."
|
||||
|
||||
if activeServer != nil {
|
||||
role, _ := bot.accountRepo.GetUserRole(userDB.ID, activeServer.ID)
|
||||
txt += fmt.Sprintf("\n\nАктивный сервер: <b>%s</b> (%s)", activeServer.Name, role)
|
||||
}
|
||||
|
||||
return c.EditOrSend(txt, menu, tele.ModeHTML)
|
||||
}
|
||||
|
||||
func (bot *Bot) renderServersMenu(c tele.Context) error {
|
||||
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
|
||||
servers, err := bot.accountRepo.GetAllAvailableServers(userDB.ID)
|
||||
if err != nil {
|
||||
return c.Send("Ошибка БД: " + err.Error())
|
||||
}
|
||||
activeServer, _ := bot.accountRepo.GetActiveServer(userDB.ID)
|
||||
|
||||
menu := &tele.ReplyMarkup{}
|
||||
var rows []tele.Row
|
||||
|
||||
for _, s := range servers {
|
||||
icon := "🔴"
|
||||
if s.IsActive {
|
||||
if activeServer != nil && activeServer.ID == s.ID {
|
||||
icon = "🟢"
|
||||
}
|
||||
// Payload: "set_server_<UUID>"
|
||||
btn := menu.Data(fmt.Sprintf("%s %s", icon, s.Name), "set_server_"+s.ID.String())
|
||||
|
||||
// Определяем роль для отображения
|
||||
role, _ := bot.accountRepo.GetUserRole(userDB.ID, s.ID)
|
||||
label := fmt.Sprintf("%s %s (%s)", icon, s.Name, role)
|
||||
|
||||
btn := menu.Data(label, "set_server_"+s.ID.String())
|
||||
rows = append(rows, menu.Row(btn))
|
||||
}
|
||||
|
||||
btnAdd := menu.Data("➕ Добавить сервер", "act_add_server")
|
||||
btnDel := menu.Data("🗑 Удалить", "act_del_server_menu")
|
||||
btnDel := menu.Data("⚙️ Управление / Удаление", "act_del_server_menu")
|
||||
btnBack := menu.Data("🔙 Назад", "nav_main")
|
||||
|
||||
rows = append(rows, menu.Row(btnAdd, btnDel))
|
||||
@@ -228,7 +381,7 @@ func (bot *Bot) renderDictsMenu(c tele.Context) error {
|
||||
|
||||
var txt string
|
||||
if err != nil {
|
||||
txt = fmt.Sprintf("⚠️ <b>Статус:</b> Ошибка (%v)", err)
|
||||
txt = fmt.Sprintf("⚠️ <b>Статус:</b> Ошибка или нет активного сервера (%v)", err)
|
||||
} else {
|
||||
lastUpdate := "—"
|
||||
if stats.LastInvoice != nil {
|
||||
@@ -255,7 +408,6 @@ func (bot *Bot) renderDictsMenu(c tele.Context) error {
|
||||
}
|
||||
|
||||
func (bot *Bot) renderBalanceMenu(c tele.Context) error {
|
||||
// Заглушка баланса
|
||||
txt := "<b>💰 Ваш баланс</b>\n\n" +
|
||||
"💵 Текущий счет: <b>0.00 ₽</b>\n" +
|
||||
"💎 Тариф: <b>Free</b>\n\n" +
|
||||
@@ -268,113 +420,208 @@ func (bot *Bot) renderBalanceMenu(c tele.Context) error {
|
||||
|
||||
func (bot *Bot) handleCallback(c tele.Context) error {
|
||||
data := c.Callback().Data
|
||||
|
||||
// FIX: Telebot v3 добавляет префикс '\f' к Unique ID кнопки.
|
||||
// Нам нужно удалить его, чтобы корректно парсить строку.
|
||||
if len(data) > 0 && data[0] == '\f' {
|
||||
data = data[1:]
|
||||
}
|
||||
|
||||
// Обработка выбора сервера "set_server_..."
|
||||
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
|
||||
|
||||
// --- SELECT SERVER ---
|
||||
if strings.HasPrefix(data, "set_server_") {
|
||||
serverIDStr := strings.TrimPrefix(data, "set_server_")
|
||||
serverIDStr = strings.TrimSpace(serverIDStr)
|
||||
|
||||
// Защита от старых форматов с разделителем |
|
||||
if idx := strings.Index(serverIDStr, "|"); idx != -1 {
|
||||
serverIDStr = serverIDStr[:idx]
|
||||
}
|
||||
|
||||
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
|
||||
|
||||
// 1. Ищем сервер в базе, чтобы убедиться что это сервер этого юзера
|
||||
servers, _ := bot.accountRepo.GetAllServers(userDB.ID)
|
||||
var found bool
|
||||
for _, s := range servers {
|
||||
if s.ID.String() == serverIDStr {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
logger.Log.Warn("User tried to select unknown server",
|
||||
zap.Int64("user_tg_id", c.Sender().ID),
|
||||
zap.String("server_id_req", serverIDStr))
|
||||
return c.Respond(&tele.CallbackResponse{Text: "Сервер не найден или доступ запрещен"})
|
||||
}
|
||||
|
||||
// 2. Делаем активным
|
||||
targetID := parseUUID(serverIDStr)
|
||||
|
||||
if err := bot.accountRepo.SetActiveServer(userDB.ID, targetID); err != nil {
|
||||
logger.Log.Error("Failed to set active server", zap.Error(err))
|
||||
return c.Respond(&tele.CallbackResponse{Text: "Ошибка смены сервера"})
|
||||
return c.Respond(&tele.CallbackResponse{Text: "Ошибка: доступ запрещен"})
|
||||
}
|
||||
|
||||
// 3. Успех
|
||||
bot.rmsFactory.ClearCacheForUser(userDB.ID) // Сброс кэша
|
||||
c.Respond(&tele.CallbackResponse{Text: "✅ Сервер выбран"})
|
||||
return bot.renderServersMenu(c) // Перерисовываем меню
|
||||
|
||||
// Важно: перерисовываем главное меню, чтобы обновилась кнопка App (появилась/пропала)
|
||||
// Но мы находимся в подменю. Логичнее остаться в ServersMenu, но кнопка App в MainMenu.
|
||||
// Пользователь нажмет "Назад" и попадет в MainMenu, где сработает renderMainMenu с новой логикой.
|
||||
return bot.renderServersMenu(c)
|
||||
}
|
||||
|
||||
// --- ЛОГИКА УДАЛЕНИЯ (новая) ---
|
||||
// --- DELETE / LEAVE SERVER ---
|
||||
if strings.HasPrefix(data, "do_del_server_") {
|
||||
serverIDStr := strings.TrimPrefix(data, "do_del_server_")
|
||||
serverIDStr = strings.TrimSpace(serverIDStr)
|
||||
|
||||
// Очистка от мусора
|
||||
if idx := strings.Index(serverIDStr, "|"); idx != -1 {
|
||||
serverIDStr = serverIDStr[:idx]
|
||||
}
|
||||
|
||||
targetID := parseUUID(serverIDStr)
|
||||
if targetID == uuid.Nil {
|
||||
return c.Respond(&tele.CallbackResponse{Text: "Некорректный ID"})
|
||||
|
||||
role, err := bot.accountRepo.GetUserRole(userDB.ID, targetID)
|
||||
if err != nil {
|
||||
return c.Respond(&tele.CallbackResponse{Text: "Ошибка прав доступа"})
|
||||
}
|
||||
|
||||
// 1. Проверяем, активен ли он сейчас
|
||||
// Нам нужно знать это ДО удаления, чтобы переключить активность
|
||||
// Но проще удалить, а потом проверить, остался ли активный сервер
|
||||
|
||||
// Удаляем
|
||||
if err := bot.accountRepo.DeleteServer(targetID); err != nil {
|
||||
logger.Log.Error("Failed to delete server", zap.Error(err))
|
||||
return c.Respond(&tele.CallbackResponse{Text: "Ошибка удаления"})
|
||||
}
|
||||
|
||||
// Сбрасываем кэш клиента в фабрике
|
||||
bot.rmsFactory.ClearCache(targetID)
|
||||
|
||||
// 2. Проверяем, есть ли активный сервер у пользователя
|
||||
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
|
||||
active, err := bot.accountRepo.GetActiveServer(userDB.ID)
|
||||
|
||||
// Если активного нет (мы удалили активный) или ошибка - назначаем новый
|
||||
if active == nil || err != nil {
|
||||
all, _ := bot.accountRepo.GetAllServers(userDB.ID)
|
||||
if len(all) > 0 {
|
||||
// Делаем активным первый попавшийся
|
||||
_ = bot.accountRepo.SetActiveServer(userDB.ID, all[0].ID)
|
||||
c.Respond(&tele.CallbackResponse{Text: "Сервер удален. Активным назначен " + all[0].Name})
|
||||
} else {
|
||||
c.Respond(&tele.CallbackResponse{Text: "Сервер удален. Список пуст."})
|
||||
if role == account.RoleOwner {
|
||||
if err := bot.accountRepo.DeleteServer(targetID); err != nil {
|
||||
return c.Respond(&tele.CallbackResponse{Text: "Ошибка удаления"})
|
||||
}
|
||||
bot.rmsFactory.ClearCacheForUser(userDB.ID)
|
||||
c.Respond(&tele.CallbackResponse{Text: "Сервер полностью удален"})
|
||||
} else {
|
||||
c.Respond(&tele.CallbackResponse{Text: "Сервер удален"})
|
||||
if err := bot.accountRepo.RemoveUserFromServer(targetID, userDB.ID); err != nil {
|
||||
return c.Respond(&tele.CallbackResponse{Text: "Ошибка выхода"})
|
||||
}
|
||||
bot.rmsFactory.ClearCacheForUser(userDB.ID)
|
||||
c.Respond(&tele.CallbackResponse{Text: "Вы покинули сервер"})
|
||||
}
|
||||
|
||||
active, _ := bot.accountRepo.GetActiveServer(userDB.ID)
|
||||
if active == nil {
|
||||
all, _ := bot.accountRepo.GetAllAvailableServers(userDB.ID)
|
||||
if len(all) > 0 {
|
||||
_ = bot.accountRepo.SetActiveServer(userDB.ID, all[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Возвращаемся в меню удаления (обновляем список)
|
||||
return bot.renderDeleteServerMenu(c)
|
||||
}
|
||||
|
||||
// --- INVITE LINK GENERATION ---
|
||||
if strings.HasPrefix(data, "gen_invite_") {
|
||||
serverIDStr := strings.TrimPrefix(data, "gen_invite_")
|
||||
link := fmt.Sprintf("https://t.me/%s?start=invite_%s", bot.b.Me.Username, serverIDStr)
|
||||
c.Respond()
|
||||
return c.Send(fmt.Sprintf("🔗 <b>Ссылка для приглашения:</b>\n\n<code>%s</code>\n\nОтправьте её сотруднику.", link), tele.ModeHTML)
|
||||
}
|
||||
|
||||
// --- ADMIN: SELECT SERVER -> SHOW USERS ---
|
||||
if strings.HasPrefix(data, "adm_srv_") {
|
||||
serverIDStr := strings.TrimPrefix(data, "adm_srv_")
|
||||
serverID := parseUUID(serverIDStr)
|
||||
return bot.renderServerUsers(c, serverID) // <--- ВЫЗОВ НОВОГО МЕТОДА
|
||||
}
|
||||
|
||||
// --- ADMIN: SELECT USER -> CONFIRM OWNERSHIP ---
|
||||
if strings.HasPrefix(data, "adm_usr_") {
|
||||
// Получаем ID связи
|
||||
connIDStr := strings.TrimPrefix(data, "adm_usr_")
|
||||
connID := parseUUID(connIDStr)
|
||||
|
||||
// Загружаем детали связи через новый метод
|
||||
link, err := bot.accountRepo.GetConnectionByID(connID)
|
||||
if err != nil {
|
||||
return c.Respond(&tele.CallbackResponse{Text: "Ошибка: связь не найдена"})
|
||||
}
|
||||
|
||||
// Проверяем роль
|
||||
if link.Role == account.RoleOwner {
|
||||
return c.Respond(&tele.CallbackResponse{Text: "Этот пользователь уже Владелец"})
|
||||
}
|
||||
|
||||
menu := &tele.ReplyMarkup{}
|
||||
|
||||
// ИСПРАВЛЕНИЕ: Для подтверждения тоже передаем ID связи
|
||||
// adm_own_yes_ + UUID = 12 + 36 = 48 байт (OK)
|
||||
btnYes := menu.Data("✅ Сделать Владельцем", fmt.Sprintf("adm_own_yes_%s", link.ID.String()))
|
||||
btnNo := menu.Data("Отмена", "adm_srv_"+link.ServerID.String())
|
||||
|
||||
menu.Inline(menu.Row(btnYes), menu.Row(btnNo))
|
||||
|
||||
txt := fmt.Sprintf("⚠️ <b>Внимание!</b>\n\nВы собираетесь передать права Владельца сервера <b>%s</b> пользователю <b>%s</b>.\n\nТекущий владелец станет Администратором.",
|
||||
link.Server.Name, link.User.FirstName)
|
||||
|
||||
return c.EditOrSend(txt, menu, tele.ModeHTML)
|
||||
}
|
||||
|
||||
// --- ADMIN: EXECUTE TRANSFER ---
|
||||
if strings.HasPrefix(data, "adm_own_yes_") {
|
||||
connIDStr := strings.TrimPrefix(data, "adm_own_yes_")
|
||||
connID := parseUUID(connIDStr)
|
||||
|
||||
link, err := bot.accountRepo.GetConnectionByID(connID)
|
||||
if err != nil {
|
||||
return c.Respond(&tele.CallbackResponse{Text: "Ошибка: связь не найдена"})
|
||||
}
|
||||
|
||||
if err := bot.accountRepo.TransferOwnership(link.ServerID, link.UserID); err != nil {
|
||||
logger.Log.Error("Ownership transfer failed", zap.Error(err))
|
||||
return c.Respond(&tele.CallbackResponse{Text: "Ошибка: " + err.Error()})
|
||||
}
|
||||
|
||||
// Уведомляем нового владельца
|
||||
go func() {
|
||||
msg := fmt.Sprintf("👑 <b>Поздравляем!</b>\n\nВам переданы права Владельца (OWNER) сервера <b>%s</b>.", link.Server.Name)
|
||||
bot.b.Send(&tele.User{ID: link.User.TelegramID}, msg, tele.ModeHTML)
|
||||
}()
|
||||
|
||||
c.Respond(&tele.CallbackResponse{Text: "Успешно!"})
|
||||
|
||||
// Возвращаемся к списку
|
||||
return bot.renderServerUsers(c, link.ServerID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Вспомогательный метод рендера списка пользователей ---
|
||||
func (bot *Bot) renderServerUsers(c tele.Context, serverID uuid.UUID) error {
|
||||
users, err := bot.accountRepo.GetServerUsers(serverID)
|
||||
if err != nil {
|
||||
return c.Respond(&tele.CallbackResponse{Text: "Ошибка загрузки юзеров"})
|
||||
}
|
||||
|
||||
menu := &tele.ReplyMarkup{}
|
||||
var rows []tele.Row
|
||||
|
||||
for _, u := range users {
|
||||
roleIcon := "👤"
|
||||
if u.Role == account.RoleOwner {
|
||||
roleIcon = "👑"
|
||||
}
|
||||
if u.Role == account.RoleAdmin {
|
||||
roleIcon = "⭐️"
|
||||
}
|
||||
|
||||
label := fmt.Sprintf("%s %s %s", roleIcon, u.User.FirstName, u.User.LastName)
|
||||
// Используем ID связи
|
||||
payload := fmt.Sprintf("adm_usr_%s", u.ID.String())
|
||||
|
||||
btn := menu.Data(label, payload)
|
||||
rows = append(rows, menu.Row(btn))
|
||||
}
|
||||
|
||||
btnBack := menu.Data("🔙 К серверам", "adm_list_servers")
|
||||
rows = append(rows, menu.Row(btnBack))
|
||||
menu.Inline(rows...)
|
||||
|
||||
// Для заголовка нам нужно имя сервера, но в users[0].Server оно есть (Preload),
|
||||
// либо если юзеров нет (пустой сервер?), то имя не узнаем без доп запроса.
|
||||
// Но пустой сервер вряд ли будет, там как минимум Owner.
|
||||
serverName := "Unknown"
|
||||
if len(users) > 0 {
|
||||
serverName = users[0].Server.Name
|
||||
}
|
||||
|
||||
return c.EditOrSend(fmt.Sprintf("👥 Пользователи сервера <b>%s</b>:", serverName), menu, tele.ModeHTML)
|
||||
}
|
||||
|
||||
func (bot *Bot) triggerSync(c tele.Context) error {
|
||||
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
|
||||
|
||||
server, err := bot.accountRepo.GetActiveServer(userDB.ID)
|
||||
if err != nil || server == nil {
|
||||
return c.Respond(&tele.CallbackResponse{Text: "Нет активного сервера"})
|
||||
}
|
||||
|
||||
role, _ := bot.accountRepo.GetUserRole(userDB.ID, server.ID)
|
||||
if role == account.RoleOperator {
|
||||
return c.Respond(&tele.CallbackResponse{Text: "⚠️ Синхронизация доступна только Админам", ShowAlert: true})
|
||||
}
|
||||
|
||||
c.Respond(&tele.CallbackResponse{Text: "Запускаю синхронизацию..."})
|
||||
|
||||
// Запускаем в фоне, но уведомляем юзера
|
||||
go func() {
|
||||
if err := bot.syncService.SyncAllData(userDB.ID); err != nil {
|
||||
logger.Log.Error("Manual sync failed", zap.Error(err))
|
||||
@@ -388,10 +635,9 @@ func (bot *Bot) triggerSync(c tele.Context) error {
|
||||
}
|
||||
|
||||
// --- FSM: ADD SERVER FLOW ---
|
||||
|
||||
func (bot *Bot) startAddServerFlow(c tele.Context) error {
|
||||
bot.fsm.SetState(c.Sender().ID, StateAddServerURL)
|
||||
return c.EditOrSend("🔗 Введите <b>URL</b> вашего сервера iikoRMS.\nПример: <code>https://iiko.myrest.ru:443</code>\n\n(Напишите 'отмена' для выхода)", tele.ModeHTML)
|
||||
return c.EditOrSend("🔗 Введите <b>URL</b> вашего сервера iikoRMS.\nПример: <code>https://resto.iiko.it</code>\n\n(Напишите 'отмена' для выхода)", tele.ModeHTML)
|
||||
}
|
||||
|
||||
func (bot *Bot) handleText(c tele.Context) error {
|
||||
@@ -399,7 +645,6 @@ func (bot *Bot) handleText(c tele.Context) error {
|
||||
state := bot.fsm.GetState(userID)
|
||||
text := strings.TrimSpace(c.Text())
|
||||
|
||||
// Глобальная отмена
|
||||
if strings.ToLower(text) == "отмена" || strings.ToLower(text) == "/cancel" {
|
||||
bot.fsm.Reset(userID)
|
||||
return bot.renderMainMenu(c)
|
||||
@@ -432,14 +677,12 @@ func (bot *Bot) handleText(c tele.Context) error {
|
||||
ctx := bot.fsm.GetContext(userID)
|
||||
msg, _ := bot.b.Send(c.Sender(), "⏳ Проверяю подключение...")
|
||||
|
||||
// 1. Проверяем авторизацию (креды)
|
||||
tempClient := bot.rmsFactory.CreateClientFromRawCredentials(ctx.TempURL, ctx.TempLogin, password)
|
||||
if err := tempClient.Auth(); err != nil {
|
||||
bot.b.Delete(msg)
|
||||
return c.Send(fmt.Sprintf("❌ Ошибка авторизации: %v\nПроверьте логин/пароль.", err))
|
||||
}
|
||||
|
||||
// 2. Пробуем узнать имя сервера
|
||||
var detectedName string
|
||||
info, err := rms.GetServerInfo(ctx.TempURL)
|
||||
if err == nil && info.ServerName != "" {
|
||||
@@ -448,30 +691,24 @@ func (bot *Bot) handleText(c tele.Context) error {
|
||||
|
||||
bot.b.Delete(msg)
|
||||
|
||||
// Сохраняем пароль во временный контекст, он нам пригодится при финальном сохранении
|
||||
bot.fsm.UpdateContext(userID, func(uCtx *UserContext) {
|
||||
uCtx.TempPassword = password
|
||||
uCtx.TempServerName = detectedName
|
||||
})
|
||||
|
||||
// Если имя нашли - предлагаем выбор
|
||||
if detectedName != "" {
|
||||
bot.fsm.SetState(userID, StateAddServerConfirmName)
|
||||
|
||||
menu := &tele.ReplyMarkup{}
|
||||
btnYes := menu.Data("✅ Да, использовать это имя", "confirm_name_yes")
|
||||
btnNo := menu.Data("✏️ Ввести другое", "confirm_name_no")
|
||||
menu.Inline(menu.Row(btnYes), menu.Row(btnNo))
|
||||
|
||||
return c.Send(fmt.Sprintf("🔎 Обнаружено имя сервера: <b>%s</b>.\nИспользовать его?", detectedName), menu, tele.ModeHTML)
|
||||
}
|
||||
|
||||
// Если имя не нашли - просим ввести вручную
|
||||
bot.fsm.SetState(userID, StateAddServerInputName)
|
||||
return c.Send("🏷 Введите <b>название</b> для этого сервера (для вашего удобства):")
|
||||
return c.Send("🏷 Введите <b>название</b> для этого сервера:")
|
||||
|
||||
case StateAddServerInputName:
|
||||
// Пользователь ввел свое название
|
||||
name := text
|
||||
if len(name) < 3 {
|
||||
return c.Send("⚠️ Название слишком короткое.")
|
||||
@@ -490,7 +727,7 @@ func (bot *Bot) handlePhoto(c tele.Context) error {
|
||||
|
||||
_, err = bot.rmsFactory.GetClientForUser(userDB.ID)
|
||||
if err != nil {
|
||||
return c.Send("⛔ У вас не настроен сервер iiko.\nИспользуйте /add_server для настройки.")
|
||||
return c.Send("⛔ Ошибка доступа к iiko или сервер не выбран.\nПроверьте статус сервера в меню.")
|
||||
}
|
||||
|
||||
photo := c.Message().Photo
|
||||
@@ -540,17 +777,18 @@ func (bot *Bot) handlePhoto(c tele.Context) error {
|
||||
}
|
||||
|
||||
menu := &tele.ReplyMarkup{}
|
||||
btnOpen := menu.WebApp("📝 Открыть накладную", &tele.WebApp{URL: fullURL})
|
||||
menu.Inline(menu.Row(btnOpen))
|
||||
|
||||
role, _ := bot.accountRepo.GetUserRole(userDB.ID, draft.RMSServerID)
|
||||
if role != account.RoleOperator {
|
||||
btnOpen := menu.WebApp("📝 Открыть накладную", &tele.WebApp{URL: fullURL})
|
||||
menu.Inline(menu.Row(btnOpen))
|
||||
} else {
|
||||
msgText += "\n\n<i>(Редактирование доступно Администратору)</i>"
|
||||
}
|
||||
|
||||
return c.Send(msgText, menu, tele.ModeHTML)
|
||||
}
|
||||
|
||||
func parseUUID(s string) uuid.UUID {
|
||||
id, _ := uuid.Parse(s)
|
||||
return id
|
||||
}
|
||||
|
||||
func (bot *Bot) handleConfirmNameYes(c tele.Context) error {
|
||||
userID := c.Sender().ID
|
||||
ctx := bot.fsm.GetContext(userID)
|
||||
@@ -566,40 +804,34 @@ func (bot *Bot) handleConfirmNameNo(c tele.Context) error {
|
||||
return c.EditOrSend("🏷 Хорошо, введите желаемое <b>название</b>:")
|
||||
}
|
||||
|
||||
// saveServerFinal - общая логика сохранения в БД
|
||||
func (bot *Bot) saveServerFinal(c tele.Context, userID int64, serverName string) error {
|
||||
ctx := bot.fsm.GetContext(userID)
|
||||
|
||||
encPass, _ := bot.cryptoManager.Encrypt(ctx.TempPassword)
|
||||
userDB, _ := bot.accountRepo.GetOrCreateUser(userID, c.Sender().Username, "", "")
|
||||
|
||||
newServer := &account.RMSServer{
|
||||
UserID: userDB.ID,
|
||||
Name: serverName,
|
||||
BaseURL: ctx.TempURL,
|
||||
Login: ctx.TempLogin,
|
||||
EncryptedPassword: encPass,
|
||||
IsActive: true,
|
||||
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())
|
||||
}
|
||||
|
||||
if err := bot.accountRepo.SaveServer(newServer); err != nil {
|
||||
return c.Send("Ошибка сохранения в БД: " + err.Error())
|
||||
}
|
||||
|
||||
bot.accountRepo.SetActiveServer(userDB.ID, newServer.ID)
|
||||
bot.fsm.Reset(userID)
|
||||
|
||||
c.Send(fmt.Sprintf("✅ Сервер <b>%s</b> успешно добавлен!", serverName), tele.ModeHTML)
|
||||
role, _ := bot.accountRepo.GetUserRole(userDB.ID, server.ID)
|
||||
c.Send(fmt.Sprintf("✅ Сервер <b>%s</b> подключен!\nВаша роль: <b>%s</b>", server.Name, role), tele.ModeHTML)
|
||||
|
||||
// Auto-sync
|
||||
go bot.syncService.SyncAllData(userDB.ID)
|
||||
if role == account.RoleOwner {
|
||||
go bot.syncService.SyncAllData(userDB.ID)
|
||||
} else {
|
||||
go bot.syncService.SyncAllData(userDB.ID)
|
||||
}
|
||||
|
||||
return bot.renderMainMenu(c)
|
||||
}
|
||||
|
||||
func (bot *Bot) renderDeleteServerMenu(c tele.Context) error {
|
||||
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
|
||||
servers, err := bot.accountRepo.GetAllServers(userDB.ID)
|
||||
servers, err := bot.accountRepo.GetAllAvailableServers(userDB.ID)
|
||||
if err != nil {
|
||||
return c.Send("Ошибка БД: " + err.Error())
|
||||
}
|
||||
@@ -612,10 +844,23 @@ func (bot *Bot) renderDeleteServerMenu(c tele.Context) error {
|
||||
var rows []tele.Row
|
||||
|
||||
for _, s := range servers {
|
||||
// Кнопка удаления для каждого сервера
|
||||
// Префикс do_del_server_
|
||||
btn := menu.Data(fmt.Sprintf("❌ %s", s.Name), "do_del_server_"+s.ID.String())
|
||||
rows = append(rows, menu.Row(btn))
|
||||
role, _ := bot.accountRepo.GetUserRole(userDB.ID, s.ID)
|
||||
|
||||
var label string
|
||||
if role == account.RoleOwner {
|
||||
label = fmt.Sprintf("❌ Удалить %s (Owner)", s.Name)
|
||||
} else {
|
||||
label = fmt.Sprintf("🚪 Покинуть %s", s.Name)
|
||||
}
|
||||
|
||||
btnAction := menu.Data(label, "do_del_server_"+s.ID.String())
|
||||
|
||||
if role == account.RoleOwner || role == account.RoleAdmin {
|
||||
btnInvite := menu.Data(fmt.Sprintf("📩 Invite %s", s.Name), "gen_invite_"+s.ID.String())
|
||||
rows = append(rows, menu.Row(btnAction, btnInvite))
|
||||
} else {
|
||||
rows = append(rows, menu.Row(btnAction))
|
||||
}
|
||||
}
|
||||
|
||||
btnBack := menu.Data("🔙 Назад к списку", "nav_servers")
|
||||
@@ -623,5 +868,10 @@ func (bot *Bot) renderDeleteServerMenu(c tele.Context) error {
|
||||
|
||||
menu.Inline(rows...)
|
||||
|
||||
return c.EditOrSend("🗑 <b>Удаление сервера</b>\n\nНажмите на сервер, который хотите удалить.\nЭто действие нельзя отменить.", menu, tele.ModeHTML)
|
||||
return c.EditOrSend("⚙️ <b>Управление серверами</b>\n\nЗдесь вы можете удалить сервер или пригласить сотрудников.", menu, tele.ModeHTML)
|
||||
}
|
||||
|
||||
func parseUUID(s string) uuid.UUID {
|
||||
id, _ := uuid.Parse(s)
|
||||
return id
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user