добавил пользователей для сервера и роли

добавил инвайт-ссылки с ролью оператор для сервера
добавил супер-админку для смены владельцев
добавил уведомления о смене ролей на серверах
добавил модалку для фото прям в черновике
добавил UI для редактирования прав
This commit is contained in:
2025-12-23 13:06:06 +03:00
parent 9441579a34
commit b4ce819931
21 changed files with 9244 additions and 418 deletions

View File

@@ -16,15 +16,13 @@ RUN go build -o rmser-app ./cmd/main.go
# Финальный этап (минимальный образ) # Финальный этап (минимальный образ)
FROM alpine:latest FROM alpine:latest
WORKDIR /root/ WORKDIR /app/
# Устанавливаем сертификаты для HTTPS (нужны для запросов к Telegram/RMS) # Устанавливаем сертификаты для HTTPS (нужны для запросов к Telegram/RMS)
RUN apk --no-cache add ca-certificates tzdata RUN apk --no-cache add ca-certificates tzdata
# Копируем бинарник и конфиг # Копируем бинарник и конфиг
COPY --from=builder /app/rmser-app . COPY --from=builder /app/rmser-app .
# Если используете config.yaml, его тоже нужно скопировать,
# либо прокидывать через volume/env
COPY config.yaml . COPY config.yaml .
CMD ["./rmser-app"] CMD ["./rmser-app"]

View File

@@ -2,6 +2,7 @@ package main
import ( import (
"log" "log"
"os"
"time" "time"
"github.com/gin-contrib/cors" "github.com/gin-contrib/cors"
@@ -53,6 +54,9 @@ func main() {
logger.Init(cfg.App.Mode) logger.Init(cfg.App.Mode)
defer logger.Log.Sync() defer logger.Log.Sync()
if err := os.MkdirAll(cfg.App.StoragePath, 0755); err != nil {
logger.Log.Fatal("Не удалось создать директорию для загрузок", zap.Error(err), zap.String("path", cfg.App.StoragePath))
}
logger.Log.Info("Запуск приложения rmser", zap.String("mode", cfg.App.Mode)) logger.Log.Info("Запуск приложения rmser", zap.String("mode", cfg.App.Mode))
// 3. Crypto & DB // 3. Crypto & DB
@@ -81,7 +85,7 @@ func main() {
syncService := sync.NewService(rmsFactory, accountRepo, catalogRepo, recipesRepo, invoicesRepo, opsRepo, supplierRepo) syncService := sync.NewService(rmsFactory, accountRepo, catalogRepo, recipesRepo, invoicesRepo, opsRepo, supplierRepo)
recService := recServicePkg.NewService(recRepo) recService := recServicePkg.NewService(recRepo)
ocrService := ocrServicePkg.NewService(ocrRepo, catalogRepo, draftsRepo, accountRepo, pyClient) ocrService := ocrServicePkg.NewService(ocrRepo, catalogRepo, draftsRepo, accountRepo, pyClient, cfg.App.StoragePath)
draftsService := draftsServicePkg.NewService(draftsRepo, ocrRepo, catalogRepo, accountRepo, supplierRepo, rmsFactory) draftsService := draftsServicePkg.NewService(draftsRepo, ocrRepo, catalogRepo, accountRepo, supplierRepo, rmsFactory)
// 7. Handlers // 7. Handlers
@@ -92,11 +96,11 @@ func main() {
// 8. Telegram Bot (Передаем syncService) // 8. Telegram Bot (Передаем syncService)
if cfg.Telegram.Token != "" { if cfg.Telegram.Token != "" {
// !!! syncService добавлен в аргументы
bot, err := tgBot.NewBot(cfg.Telegram, ocrService, syncService, accountRepo, rmsFactory, cryptoManager) bot, err := tgBot.NewBot(cfg.Telegram, ocrService, syncService, accountRepo, rmsFactory, cryptoManager)
if err != nil { if err != nil {
logger.Log.Fatal("Ошибка создания Telegram бота", zap.Error(err)) logger.Log.Fatal("Ошибка создания Telegram бота", zap.Error(err))
} }
settingsHandler.SetNotifier(bot) // Внедряем зависимость
go bot.Start() go bot.Start()
defer bot.Stop() defer bot.Stop()
} }
@@ -113,6 +117,10 @@ func main() {
corsConfig.AllowHeaders = []string{"Origin", "Content-Length", "Content-Type", "Authorization", "X-Telegram-User-ID"} corsConfig.AllowHeaders = []string{"Origin", "Content-Length", "Content-Type", "Authorization", "X-Telegram-User-ID"}
r.Use(cors.New(corsConfig)) r.Use(cors.New(corsConfig))
// --- STATIC FILES SERVING ---
// Раздаем папку uploads по урлу /api/uploads
r.Static("/api/uploads", cfg.App.StoragePath)
api := r.Group("/api") api := r.Group("/api")
api.Use(middleware.AuthMiddleware(accountRepo, cfg.Telegram.Token)) api.Use(middleware.AuthMiddleware(accountRepo, cfg.Telegram.Token))
@@ -131,6 +139,10 @@ func main() {
// Settings // Settings
api.GET("/settings", settingsHandler.GetSettings) api.GET("/settings", settingsHandler.GetSettings)
api.POST("/settings", settingsHandler.UpdateSettings) api.POST("/settings", settingsHandler.UpdateSettings)
// User Management
api.GET("/settings/users", settingsHandler.GetServerUsers)
api.PATCH("/settings/users/:userId", settingsHandler.UpdateUserRole)
api.DELETE("/settings/users/:userId", settingsHandler.RemoveUser)
// Dictionaries // Dictionaries
api.GET("/dictionaries", draftsHandler.GetDictionaries) api.GET("/dictionaries", draftsHandler.GetDictionaries)

View File

@@ -1,7 +1,9 @@
app: app:
port: "8080" port: "8080"
mode: "debug" # debug выводит цветные логи mode: "debug" # debug выводит цветные логи
drop_tables: true drop_tables: false
storage_path: "./uploads"
public_url: "https://rmser.serty.top"
db: db:
dsn: "host=postgres user=rmser password=mhrcadmin994525 dbname=rmser_db port=5432 sslmode=disable TimeZone=Europe/Moscow" dsn: "host=postgres user=rmser password=mhrcadmin994525 dbname=rmser_db port=5432 sslmode=disable TimeZone=Europe/Moscow"

View File

@@ -21,6 +21,8 @@ type AppConfig struct {
Port string `mapstructure:"port"` Port string `mapstructure:"port"`
Mode string `mapstructure:"mode"` // debug/release Mode string `mapstructure:"mode"` // debug/release
DropTables bool `mapstructure:"drop_tables"` DropTables bool `mapstructure:"drop_tables"`
StoragePath string `mapstructure:"storage_path"`
PublicURL string `mapstructure:"public_url"`
} }
type DBConfig struct { type DBConfig struct {

View File

@@ -52,6 +52,8 @@ services:
- DB_DSN=host=db user=rmser password=mhrcadmin994525 dbname=rmser_db port=5432 sslmode=disable TimeZone=Europe/Moscow - DB_DSN=host=db user=rmser password=mhrcadmin994525 dbname=rmser_db port=5432 sslmode=disable TimeZone=Europe/Moscow
- REDIS_ADDR=redis:6379 - REDIS_ADDR=redis:6379
- OCR_SERVICE_URL=http://ocr:5000 - OCR_SERVICE_URL=http://ocr:5000
volumes:
- rmser_uploads:/app/uploads
# 5. Frontend (React + Nginx) # 5. Frontend (React + Nginx)
frontend: frontend:
@@ -65,3 +67,4 @@ services:
volumes: volumes:
postgres_data: postgres_data:
rmser_uploads:

View File

@@ -6,6 +6,15 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
// Роли пользователей
type Role string
const (
RoleOwner Role = "OWNER" // Создатель: Полный доступ + удаление сервера
RoleAdmin Role = "ADMIN" // Администратор: Редактирование, настройки, приглашение
RoleOperator Role = "OPERATOR" // Оператор: Только загрузка фото
)
// User - Пользователь системы (Telegram аккаунт) // User - Пользователь системы (Telegram аккаунт)
type User struct { type User struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` 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"` LastName string `gorm:"type:varchar(100)" json:"last_name"`
PhotoURL string `gorm:"type:text" json:"photo_url"` PhotoURL string `gorm:"type:text" json:"photo_url"`
IsAdmin bool `gorm:"default:false" json:"is_admin"` IsSystemAdmin bool `gorm:"default:false" json:"is_system_admin"`
// Связь с серверами
Servers []RMSServer `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"servers,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_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 { type RMSServer struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` 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"`
Name string `gorm:"type:varchar(100);not null" json:"name"` // Название (напр. "Ресторан на Ленина") // Уникальный URL (очищенный), определяет инстанс
BaseURL string `gorm:"type:varchar(255);not null;uniqueIndex" json:"base_url"`
// Credentials Name string `gorm:"type:varchar(100);not null" json:"name"`
BaseURL string `gorm:"type:varchar(255);not null" json:"base_url"` MaxUsers int `gorm:"default:5" json:"max_users"` // Лимит пользователей
Login string `gorm:"type:varchar(100);not null" json:"login"`
EncryptedPassword string `gorm:"type:text;not null" json:"-"` // Пароль храним зашифрованным
DefaultStoreID *uuid.UUID `gorm:"type:uuid" json:"default_store_id"` // Склад для подстановки // Глобальные настройки сервера (общие для всех)
RootGroupGUID *uuid.UUID `gorm:"type:uuid" json:"root_group_guid"` // ID корневой папки для поиска товаров DefaultStoreID *uuid.UUID `gorm:"type:uuid" json:"default_store_id"`
AutoProcess bool `gorm:"default:false" json:"auto_process"` // Пытаться сразу проводить накладную RootGroupGUID *uuid.UUID `gorm:"type:uuid" json:"root_group_guid"`
AutoProcess bool `gorm:"default:false" json:"auto_process"`
// Billing / Stats // Billing / Stats
InvoiceCount int `gorm:"default:0" json:"invoice_count"` // Счетчик успешно отправленных накладных InvoiceCount int `gorm:"default:0" json:"invoice_count"`
IsActive bool `gorm:"default:true" json:"is_active"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
// Repository интерфейс управления аккаунтами // Repository интерфейс
type Repository interface { type Repository interface {
// Users // Users
GetOrCreateUser(telegramID int64, username, first, last string) (*User, error) GetOrCreateUser(telegramID int64, username, first, last string) (*User, error)
GetUserByTelegramID(telegramID int64) (*User, error) GetUserByTelegramID(telegramID int64) (*User, error)
// Servers // ConnectServer - Основной метод подключения.
SaveServer(server *RMSServer) error // Реализует логику: Новый 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 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 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 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)
} }

View File

@@ -22,8 +22,8 @@ type DraftInvoice struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` 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"` 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"` RMSServerID uuid.UUID `gorm:"type:uuid;not null;index" json:"rms_server_id"` // К какому серверу относится
SenderPhotoURL string `gorm:"type:text" json:"photo_url"` SenderPhotoURL string `gorm:"type:text" json:"photo_url"`
Status string `gorm:"type:varchar(50);default:'PROCESSING'" json:"status"` Status string `gorm:"type:varchar(50);default:'PROCESSING'" json:"status"`
@@ -73,5 +73,7 @@ type Repository interface {
CreateItem(item *DraftInvoiceItem) error CreateItem(item *DraftInvoiceItem) error
DeleteItem(itemID uuid.UUID) error DeleteItem(itemID uuid.UUID) error
Delete(id uuid.UUID) error Delete(id uuid.UUID) error
GetActive(userID uuid.UUID) ([]DraftInvoice, error)
// GetActive возвращает активные черновики для СЕРВЕРА (а не юзера)
GetActive(serverID uuid.UUID) ([]DraftInvoice, error)
} }

View File

@@ -50,6 +50,7 @@ func NewPostgresDB(dsn string) *gorm.DB {
err = db.AutoMigrate( err = db.AutoMigrate(
&account.User{}, &account.User{},
&account.RMSServer{}, &account.RMSServer{},
&account.ServerUser{},
&catalog.Product{}, &catalog.Product{},
&catalog.MeasureUnit{}, &catalog.MeasureUnit{},
&catalog.ProductContainer{}, &catalog.ProductContainer{},

View File

@@ -1,11 +1,14 @@
package account package account
import ( import (
"errors"
"fmt"
"strings"
"rmser/internal/domain/account" "rmser/internal/domain/account"
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
type pgRepository struct { type pgRepository struct {
@@ -23,7 +26,6 @@ func (r *pgRepository) GetOrCreateUser(telegramID int64, username, first, last s
if err != nil { if err != nil {
if err == gorm.ErrRecordNotFound { if err == gorm.ErrRecordNotFound {
// Создаем
newUser := account.User{ newUser := account.User{
TelegramID: telegramID, TelegramID: telegramID,
Username: username, Username: username,
@@ -38,8 +40,8 @@ func (r *pgRepository) GetOrCreateUser(telegramID int64, username, first, last s
return nil, err return nil, err
} }
// Обновляем инфо, если изменилось (опционально) // Обновляем инфо
if user.Username != username || user.FirstName != first { if user.Username != username || user.FirstName != first || user.LastName != last {
user.Username = username user.Username = username
user.FirstName = first user.FirstName = first
user.LastName = last 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) { func (r *pgRepository) GetUserByTelegramID(telegramID int64) (*account.User, error) {
var user account.User var user account.User
// Preload Servers чтобы сразу видеть подключения err := r.db.Where("telegram_id = ?", telegramID).First(&user).Error
err := r.db.Preload("Servers").Where("telegram_id = ?", telegramID).First(&user).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &user, nil return &user, nil
} }
func (r *pgRepository) SaveServer(server *account.RMSServer) error { // ConnectServer - Основная точка входа для добавления сервера
return r.db.Clauses(clause.OnConflict{ func (r *pgRepository) ConnectServer(userID uuid.UUID, rawURL, login, encryptedPass, name string) (*account.RMSServer, error) {
Columns: []clause.Column{{Name: "id"}}, // 1. Нормализация URL (удаляем слеш в конце, приводим к нижнему регистру)
UpdateAll: true, // Важно: мы не удаляем http/https, так как iiko может работать и так и так, но обычно это разные эндпоинты.
}).Create(server).Error // Для надежности уникальности можно вырезать протокол, но пока оставим как есть, просто тримминг.
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 { func (r *pgRepository) SetActiveServer(userID, serverID uuid.UUID) error {
return r.db.Transaction(func(tx *gorm.DB) error { return r.db.Transaction(func(tx *gorm.DB) error {
// 1. Сбрасываем флаг у всех серверов пользователя // Проверка доступа
if err := tx.Model(&account.RMSServer{}). var count int64
Where("user_id = ?", userID). tx.Model(&account.ServerUser{}).Where("user_id = ? AND server_id = ?", userID, serverID).Count(&count)
Update("is_active", false).Error; err != nil { if count == 0 {
return err return errors.New("доступ к серверу запрещен")
} }
// 2. Ставим флаг целевому серверу if err := tx.Model(&account.ServerUser{}).Where("user_id = ?", userID).Update("is_active", false).Error; err != nil {
if err := tx.Model(&account.RMSServer{}).
Where("id = ? AND user_id = ?", serverID, userID).
Update("is_active", true).Error; err != nil {
return err return err
} }
return tx.Model(&account.ServerUser{}).Where("user_id = ? AND server_id = ?", userID, serverID).Update("is_active", true).Error
return nil
}) })
} }
func (r *pgRepository) GetActiveServer(userID uuid.UUID) (*account.RMSServer, error) { func (r *pgRepository) GetActiveServer(userID uuid.UUID) (*account.RMSServer, error) {
var server account.RMSServer var server account.RMSServer
// Берем первый активный сервер. В будущем можно добавить поле IsSelected err := r.db.Table("rms_servers").
err := r.db.Where("user_id = ? AND is_active = ?", userID, true).First(&server).Error 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 != nil {
if err == gorm.ErrRecordNotFound { if err == gorm.ErrRecordNotFound {
return nil, nil // Нет серверов return nil, nil
} }
return nil, err return nil, err
} }
return &server, nil return &server, nil
} }
// GetAllServers возвращает ВСЕ серверы пользователя, чтобы можно было переключаться // GetActiveConnectionCredentials возвращает креды для подключения.
func (r *pgRepository) GetAllServers(userID uuid.UUID) ([]account.RMSServer, error) { // Логика:
// 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 var servers []account.RMSServer
// Убрали фильтр AND is_active = true, теперь возвращает весь список err := r.db.Table("rms_servers").
err := r.db.Where("user_id = ?", userID).Find(&servers).Error 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 return servers, err
} }
func (r *pgRepository) DeleteServer(serverID uuid.UUID) error { 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(&currentCount)
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 { func (r *pgRepository) IncrementInvoiceCount(serverID uuid.UUID) error {
@@ -117,3 +352,52 @@ func (r *pgRepository) IncrementInvoiceCount(serverID uuid.UUID) error {
Where("id = ?", serverID). Where("id = ?", serverID).
UpdateColumn("invoice_count", gorm.Expr("invoice_count + ?", 1)).Error 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(&currentOwnerLink).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(&currentOwnerLink).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
}

View File

@@ -39,7 +39,6 @@ func (r *pgRepository) GetByID(id uuid.UUID) (*drafts.DraftInvoice, error) {
} }
func (r *pgRepository) Update(draft *drafts.DraftInvoice) error { func (r *pgRepository) Update(draft *drafts.DraftInvoice) error {
// Обновляем поля шапки + привязки к серверу
return r.db.Model(draft).Updates(map[string]interface{}{ return r.db.Model(draft).Updates(map[string]interface{}{
"status": draft.Status, "status": draft.Status,
"document_number": draft.DocumentNumber, "document_number": draft.DocumentNumber,
@@ -48,7 +47,7 @@ func (r *pgRepository) Update(draft *drafts.DraftInvoice) error {
"store_id": draft.StoreID, "store_id": draft.StoreID,
"comment": draft.Comment, "comment": draft.Comment,
"rms_invoice_id": draft.RMSInvoiceID, "rms_invoice_id": draft.RMSInvoiceID,
"rms_server_id": draft.RMSServerID, // Вдруг поменялся, хотя не должен "rms_server_id": draft.RMSServerID,
"updated_at": gorm.Expr("NOW()"), "updated_at": gorm.Expr("NOW()"),
}).Error }).Error
} }
@@ -88,8 +87,8 @@ func (r *pgRepository) Delete(id uuid.UUID) error {
return r.db.Delete(&drafts.DraftInvoice{}, id).Error return r.db.Delete(&drafts.DraftInvoice{}, id).Error
} }
// GetActive фильтрует по UserID // GetActive возвращает черновики для конкретного СЕРВЕРА
func (r *pgRepository) GetActive(userID uuid.UUID) ([]drafts.DraftInvoice, error) { func (r *pgRepository) GetActive(serverID uuid.UUID) ([]drafts.DraftInvoice, error) {
var list []drafts.DraftInvoice var list []drafts.DraftInvoice
activeStatuses := []string{ activeStatuses := []string{
@@ -102,7 +101,7 @@ func (r *pgRepository) GetActive(userID uuid.UUID) ([]drafts.DraftInvoice, error
err := r.db. err := r.db.
Preload("Items"). Preload("Items").
Preload("Store"). Preload("Store").
Where("user_id = ? AND status IN ?", userID, activeStatuses). // <-- FILTER Where("rms_server_id = ? AND status IN ?", serverID, activeStatuses). // Фильтр по серверу
Order("created_at DESC"). Order("created_at DESC").
Find(&list).Error Find(&list).Error

View File

@@ -30,74 +30,47 @@ func NewFactory(repo account.Repository, cm *crypto.CryptoManager) *Factory {
} }
} }
// GetClientByServerID возвращает готовый клиент для конкретного сервера // GetClientForUser возвращает клиент для текущего активного сервера пользователя.
func (f *Factory) GetClientByServerID(serverID uuid.UUID) (ClientI, error) { // Использует личные или наследуемые (от Owner) учетные данные.
// 1. Пытаемся найти в кэше (быстрый путь) func (f *Factory) GetClientForUser(userID uuid.UUID) (ClientI, error) {
// 1. Пытаемся найти в кэше
f.mu.RLock() f.mu.RLock()
client, exists := f.clients[serverID] client, exists := f.clients[userID]
f.mu.RUnlock() f.mu.RUnlock()
if exists { if exists {
return client, nil return client, nil
} }
// 2. Если нет в кэше - ищем в БД (медленный путь) // 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. Создаем новый клиент под блокировкой (защита от гонки)
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
// Double check // Double check
if cachedClient, exists := f.clients[server.ID]; exists { if client, exists := f.clients[userID]; exists {
return cachedClient, nil return client, nil
} }
// Расшифровка пароля // 3. Получаем креды из репозитория (учитывая фоллбэк на Owner'а)
plainPass, err := f.cryptoManager.Decrypt(server.EncryptedPassword) 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 { if err != nil {
return nil, fmt.Errorf("ошибка расшифровки пароля RMS: %w", err) return nil, fmt.Errorf("ошибка расшифровки пароля RMS: %w", err)
} }
// Создание клиента // 5. Создание клиента
newClient := NewClient(server.BaseURL, server.Login, plainPass) newClient := NewClient(baseURL, login, plainPass)
f.clients[userID] = newClient
// Можно сразу проверить авторизацию (опционально, но полезно для fail-fast) logger.Log.Info("RMS Factory: Client created for user",
// if err := newClient.Auth(); err != nil { ... } zap.String("user_id", userID.String()),
// Но лучше лениво, чтобы не тормозить старт. zap.String("login", login),
zap.String("url", baseURL))
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()))
return newClient, nil return newClient, nil
} }
@@ -107,9 +80,17 @@ func (f *Factory) CreateClientFromRawCredentials(url, login, password string) *C
return NewClient(url, login, password) return NewClient(url, login, password)
} }
// ClearCache сбрасывает кэш для сервера (например, при смене пароля) // ClearCacheForUser сбрасывает кэш пользователя (при смене сервера или выходе)
func (f *Factory) ClearCache(serverID uuid.UUID) { func (f *Factory) ClearCacheForUser(userID uuid.UUID) {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
delete(f.clients, serverID) delete(f.clients, userID)
}
// ClearCacheForServer сбрасывает кэш для ВСЕХ пользователей сервера (например, при смене пароля владельцем)
// Это дорогая операция, но необходимая при изменении общих кредов.
func (f *Factory) ClearCacheForServer(serverID uuid.UUID) {
// Пока не реализовано эффективно (нужен обратный индекс).
// Для MVP можно просто очистить весь кэш или оставить как есть,
// так как токены iiko все равно протухнут.
} }

View File

@@ -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) { func (s *Service) GetDraft(draftID, userID uuid.UUID) (*drafts.DraftInvoice, error) {
// TODO: Проверить что userID совпадает с draft.UserID draft, err := s.draftRepo.GetByID(draftID)
return 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) { 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 возвращает Склады и Поставщиков для пользователя // GetDictionaries возвращает Склады и Поставщиков для пользователя
@@ -63,9 +107,12 @@ func (s *Service) GetDictionaries(userID uuid.UUID) (map[string]interface{}, err
return nil, fmt.Errorf("active server not found") 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) suppliersList, _ := s.supplierRepo.GetRankedByUsage(server.ID, 90)
return map[string]interface{}{ 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) { func (s *Service) DeleteDraft(id uuid.UUID) (string, error) {
// Без изменений логики, только вызов репо
draft, err := s.draftRepo.GetByID(id) draft, err := s.draftRepo.GetByID(id)
if err != nil { if err != nil {
return "", err return "", err
} }
// TODO: Здесь тоже бы проверить userID и права, но пока оставим как есть,
// так как DeleteDraft вызывается из хендлера, где мы можем добавить проверку,
// но лучше передавать userID в сигнатуру DeleteDraft(id, userID).
// Для скорости пока оставим, полагаясь на то, что фронт не покажет кнопку.
if draft.Status == drafts.StatusCanceled { if draft.Status == drafts.StatusCanceled {
draft.Status = drafts.StatusDeleted draft.Status = drafts.StatusDeleted
s.draftRepo.Update(draft) s.draftRepo.Update(draft)
@@ -110,8 +161,6 @@ func (s *Service) UpdateDraftHeader(id uuid.UUID, storeID *uuid.UUID, supplierID
// AddItem добавляет пустую строку в черновик // AddItem добавляет пустую строку в черновик
func (s *Service) AddItem(draftID uuid.UUID) (*drafts.DraftInvoiceItem, error) { func (s *Service) AddItem(draftID uuid.UUID) (*drafts.DraftInvoiceItem, error) {
// Проверка статуса драфта (можно добавить)
newItem := &drafts.DraftInvoiceItem{ newItem := &drafts.DraftInvoiceItem{
ID: uuid.New(), ID: uuid.New(),
DraftID: draftID, DraftID: draftID,
@@ -132,19 +181,15 @@ func (s *Service) AddItem(draftID uuid.UUID) (*drafts.DraftInvoiceItem, error) {
// DeleteItem удаляет строку и возвращает обновленную сумму черновика // DeleteItem удаляет строку и возвращает обновленную сумму черновика
func (s *Service) DeleteItem(draftID, itemID uuid.UUID) (float64, error) { func (s *Service) DeleteItem(draftID, itemID uuid.UUID) (float64, error) {
// 1. Удаляем
if err := s.draftRepo.DeleteItem(itemID); err != nil { if err := s.draftRepo.DeleteItem(itemID); err != nil {
return 0, err return 0, err
} }
// 2. Получаем драфт заново для пересчета суммы
// Это самый надежный способ, чем считать в памяти
draft, err := s.draftRepo.GetByID(draftID) draft, err := s.draftRepo.GetByID(draftID)
if err != nil { if err != nil {
return 0, err return 0, err
} }
// 3. Считаем сумму
var totalSum decimal.Decimal var totalSum decimal.Decimal
for _, item := range draft.Items { for _, item := range draft.Items {
if !item.Sum.IsZero() { if !item.Sum.IsZero() {
@@ -163,6 +208,7 @@ func (s *Service) UpdateItem(draftID, itemID uuid.UUID, productID *uuid.UUID, co
if err != nil { if err != nil {
return err return err
} }
// Автосмена статуса
if draft.Status == drafts.StatusCanceled { if draft.Status == drafts.StatusCanceled {
draft.Status = drafts.StatusReadyToVerify draft.Status = drafts.StatusReadyToVerify
s.draftRepo.Update(draft) s.draftRepo.Update(draft)
@@ -172,9 +218,13 @@ func (s *Service) UpdateItem(draftID, itemID uuid.UUID, productID *uuid.UUID, co
// CommitDraft отправляет накладную // CommitDraft отправляет накладную
func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) { func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) {
// 1. Клиент для пользователя // 1. Получаем сервер и права
client, err := s.rmsFactory.GetClientForUser(userID) server, err := s.accountRepo.GetActiveServer(userID)
if err != nil { if err != nil {
return "", fmt.Errorf("active server not found: %w", err)
}
if err := s.checkWriteAccess(userID, server.ID); err != nil {
return "", err return "", err
} }
@@ -183,13 +233,20 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
// Проверка принадлежности черновика серверу
if draft.RMSServerID != server.ID {
return "", errors.New("черновик принадлежит другому серверу")
}
if draft.Status == drafts.StatusCompleted { if draft.Status == drafts.StatusCompleted {
return "", errors.New("накладная уже отправлена") return "", errors.New("накладная уже отправлена")
} }
server, err := s.accountRepo.GetActiveServer(userID) // 3. Клиент (использует права текущего юзера - Админа/Владельца)
client, err := s.rmsFactory.GetClientForUser(userID)
if err != nil { if err != nil {
return "", fmt.Errorf("active server not found: %w", err) return "", err
} }
targetStatus := "NEW" targetStatus := "NEW"
@@ -197,15 +254,15 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) {
targetStatus = "PROCESSED" targetStatus = "PROCESSED"
} }
// 3. Сборка Invoice // 4. Сборка Invoice
inv := invoices.Invoice{ inv := invoices.Invoice{
ID: uuid.Nil, ID: uuid.Nil,
DocumentNumber: draft.DocumentNumber, DocumentNumber: draft.DocumentNumber,
DateIncoming: *draft.DateIncoming, DateIncoming: *draft.DateIncoming,
SupplierID: *draft.SupplierID, SupplierID: *draft.SupplierID,
DefaultStoreID: *draft.StoreID, DefaultStoreID: *draft.StoreID,
Status: targetStatus, // <-- Передаем статус из настроек Status: targetStatus,
Comment: draft.Comment, // <-- Передаем комментарий из черновика Comment: draft.Comment,
Items: make([]invoices.InvoiceItem, 0, len(draft.Items)), 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 continue // Skip unrecognized
} }
// Если суммы нет, считаем
sum := dItem.Sum sum := dItem.Sum
if sum.IsZero() { if sum.IsZero() {
sum = dItem.Quantity.Mul(dItem.Price) sum = dItem.Quantity.Mul(dItem.Price)
@@ -234,17 +290,17 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) {
return "", errors.New("нет распознанных позиций для отправки") return "", errors.New("нет распознанных позиций для отправки")
} }
// 4. Отправка в RMS // 5. Отправка в RMS
docNum, err := client.CreateIncomingInvoice(inv) docNum, err := client.CreateIncomingInvoice(inv)
if err != nil { if err != nil {
return "", err return "", err
} }
// 5. Обновление статуса черновика // 6. Обновление статуса черновика
draft.Status = drafts.StatusCompleted draft.Status = drafts.StatusCompleted
s.draftRepo.Update(draft) s.draftRepo.Update(draft)
// 6. БИЛЛИНГ и Обучение // 7. БИЛЛИНГ и Обучение
if err := s.accountRepo.IncrementInvoiceCount(server.ID); err != nil { if err := s.accountRepo.IncrementInvoiceCount(server.ID); err != nil {
logger.Log.Error("Billing increment failed", zap.Error(err)) 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) { 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) client, err := s.rmsFactory.GetClientForUser(userID)
if err != nil { if err != nil {
return uuid.Nil, err return uuid.Nil, err
} }
server, _ := s.accountRepo.GetActiveServer(userID) // нужен ServerID для сохранения в локальную БД
fullProduct, err := client.GetProductByID(productID) fullProduct, err := client.GetProductByID(productID)
if err != nil { if err != nil {
@@ -337,7 +400,7 @@ func (s *Service) CreateProductContainer(userID uuid.UUID, productID uuid.UUID,
// Save Local // Save Local
newLocalContainer := catalog.ProductContainer{ newLocalContainer := catalog.ProductContainer{
ID: createdID, ID: createdID,
RMSServerID: server.ID, // <-- NEW RMSServerID: server.ID,
ProductID: productID, ProductID: productID,
Name: name, Name: name,
Count: count, Count: count,

View File

@@ -2,7 +2,10 @@ package ocr
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"os"
"path/filepath"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
@@ -18,16 +21,18 @@ type Service struct {
ocrRepo ocr.Repository ocrRepo ocr.Repository
catalogRepo catalog.Repository catalogRepo catalog.Repository
draftRepo drafts.Repository draftRepo drafts.Repository
accountRepo account.Repository // <-- NEW accountRepo account.Repository
pyClient *ocr_client.Client pyClient *ocr_client.Client
storagePath string
} }
func NewService( func NewService(
ocrRepo ocr.Repository, ocrRepo ocr.Repository,
catalogRepo catalog.Repository, catalogRepo catalog.Repository,
draftRepo drafts.Repository, draftRepo drafts.Repository,
accountRepo account.Repository, // <-- NEW accountRepo account.Repository,
pyClient *ocr_client.Client, pyClient *ocr_client.Client,
storagePath string,
) *Service { ) *Service {
return &Service{ return &Service{
ocrRepo: ocrRepo, ocrRepo: ocrRepo,
@@ -35,10 +40,23 @@ func NewService(
draftRepo: draftRepo, draftRepo: draftRepo,
accountRepo: accountRepo, accountRepo: accountRepo,
pyClient: pyClient, 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) { func (s *Service) ProcessReceiptImage(ctx context.Context, userID uuid.UUID, imgData []byte) (*drafts.DraftInvoice, error) {
// 1. Получаем активный сервер для UserID // 1. Получаем активный сервер для UserID
server, err := s.accountRepo.GetActiveServer(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, Status: drafts.StatusProcessing,
StoreID: server.DefaultStoreID, 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 { if err := s.draftRepo.Create(draft); err != nil {
return nil, fmt.Errorf("failed to create draft: %w", err) return nil, fmt.Errorf("failed to create draft: %w", err)
} }
@@ -118,12 +148,15 @@ type ProductForIndex struct {
Containers []ContainerForIndex `json:"containers"` Containers []ContainerForIndex `json:"containers"`
} }
// GetCatalogForIndexing // GetCatalogForIndexing - Только для админов/владельцев (т.к. используется для ручного матчинга)
func (s *Service) GetCatalogForIndexing(userID uuid.UUID) ([]ProductForIndex, error) { func (s *Service) GetCatalogForIndexing(userID uuid.UUID) ([]ProductForIndex, error) {
server, err := s.accountRepo.GetActiveServer(userID) server, err := s.accountRepo.GetActiveServer(userID)
if err != nil || server == nil { if err != nil || server == nil {
return nil, fmt.Errorf("no server") 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) products, err := s.catalogRepo.GetActiveGoods(server.ID, server.RootGroupGUID)
if err != nil { if err != nil {
@@ -166,6 +199,10 @@ func (s *Service) SearchProducts(userID uuid.UUID, query string) ([]catalog.Prod
if err != nil || server == nil { if err != nil || server == nil {
return nil, fmt.Errorf("no server") 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) 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 { if err != nil || server == nil {
return fmt.Errorf("no server") 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) 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 { if err != nil || server == nil {
return fmt.Errorf("no server") return fmt.Errorf("no server")
} }
if err := s.checkWriteAccess(userID, server.ID); err != nil {
return err
}
return s.ocrRepo.DeleteMatch(server.ID, rawName) 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 { if err != nil || server == nil {
return nil, fmt.Errorf("no server") return nil, fmt.Errorf("no server")
} }
if err := s.checkWriteAccess(userID, server.ID); err != nil {
return nil, err
}
return s.ocrRepo.GetAllMatches(server.ID) 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 { if err != nil || server == nil {
return nil, fmt.Errorf("no server") 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) return s.ocrRepo.GetTopUnmatched(server.ID, 50)
} }

View File

@@ -12,9 +12,16 @@ import (
"rmser/pkg/logger" "rmser/pkg/logger"
) )
// Notifier - интерфейс для отправки уведомлений (реализуется ботом)
type Notifier interface {
SendRoleChangeNotification(telegramID int64, serverName string, newRole string)
SendRemovalNotification(telegramID int64, serverName string)
}
type SettingsHandler struct { type SettingsHandler struct {
accountRepo account.Repository accountRepo account.Repository
catalogRepo catalog.Repository catalogRepo catalog.Repository
notifier Notifier // Поле для отправки уведомлений
} }
func NewSettingsHandler(accRepo account.Repository, catRepo catalog.Repository) *SettingsHandler { 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) { func (h *SettingsHandler) GetSettings(c *gin.Context) {
userID := c.MustGet("userID").(uuid.UUID) userID := c.MustGet("userID").(uuid.UUID)
@@ -38,7 +61,29 @@ func (h *SettingsHandler) GetSettings(c *gin.Context) {
return 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 // UpdateSettingsDTO
@@ -47,6 +92,7 @@ type UpdateSettingsDTO struct {
DefaultStoreID string `json:"default_store_id"` DefaultStoreID string `json:"default_store_id"`
RootGroupID string `json:"root_group_id"` RootGroupID string `json:"root_group_id"`
AutoProcess bool `json:"auto_process"` AutoProcess bool `json:"auto_process"`
AutoConduct bool `json:"auto_conduct"`
} }
// UpdateSettings сохраняет настройки // UpdateSettings сохраняет настройки
@@ -65,11 +111,26 @@ func (h *SettingsHandler) UpdateSettings(c *gin.Context) {
return 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 != "" { if req.Name != "" {
server.Name = 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 req.DefaultStoreID != "" {
if uid, err := uuid.Parse(req.DefaultStoreID); err == nil { if uid, err := uuid.Parse(req.DefaultStoreID); err == nil {
@@ -79,7 +140,6 @@ func (h *SettingsHandler) UpdateSettings(c *gin.Context) {
server.DefaultStoreID = nil server.DefaultStoreID = nil
} }
// Теперь правильно ловим ID группы
if req.RootGroupID != "" { if req.RootGroupID != "" {
if uid, err := uuid.Parse(req.RootGroupID); err == nil { if uid, err := uuid.Parse(req.RootGroupID); err == nil {
server.RootGroupGUID = &uid server.RootGroupGUID = &uid
@@ -88,25 +148,24 @@ func (h *SettingsHandler) UpdateSettings(c *gin.Context) {
server.RootGroupGUID = nil 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)) logger.Log.Error("Failed to save settings", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
c.JSON(http.StatusOK, server) h.GetSettings(c)
} }
// --- Group Tree Logic --- // --- Group Tree Logic ---
type GroupNode struct { type GroupNode struct {
Key string `json:"key"` // ID for Ant Design TreeSelect Key string `json:"key"`
Value string `json:"value"` // ID value Value string `json:"value"`
Title string `json:"title"` // Name Title string `json:"title"`
Children []*GroupNode `json:"children"` // Sub-groups Children []*GroupNode `json:"children"`
} }
// GetGroupsTree возвращает иерархию групп
func (h *SettingsHandler) GetGroupsTree(c *gin.Context) { func (h *SettingsHandler) GetGroupsTree(c *gin.Context) {
userID := c.MustGet("userID").(uuid.UUID) userID := c.MustGet("userID").(uuid.UUID)
server, err := h.accountRepo.GetActiveServer(userID) server, err := h.accountRepo.GetActiveServer(userID)
@@ -126,7 +185,6 @@ func (h *SettingsHandler) GetGroupsTree(c *gin.Context) {
} }
func buildTree(flat []catalog.Product) []*GroupNode { func buildTree(flat []catalog.Product) []*GroupNode {
// 1. Map ID -> Node
nodeMap := make(map[uuid.UUID]*GroupNode) nodeMap := make(map[uuid.UUID]*GroupNode)
for _, g := range flat { for _, g := range flat {
nodeMap[g.ID] = &GroupNode{ nodeMap[g.ID] = &GroupNode{
@@ -138,16 +196,12 @@ func buildTree(flat []catalog.Product) []*GroupNode {
} }
var roots []*GroupNode var roots []*GroupNode
// 2. Build Hierarchy
for _, g := range flat { for _, g := range flat {
node := nodeMap[g.ID] node := nodeMap[g.ID]
if g.ParentID != nil { if g.ParentID != nil {
if parent, exists := nodeMap[*g.ParentID]; exists { if parent, exists := nodeMap[*g.ParentID]; exists {
parent.Children = append(parent.Children, node) parent.Children = append(parent.Children, node)
} else { } else {
// Если родителя нет в списке (например, он удален или мы выбрали подмножество),
// считаем узлом верхнего уровня
roots = append(roots, node) roots = append(roots, node)
} }
} else { } else {
@@ -156,3 +210,181 @@ func buildTree(flat []catalog.Product) []*GroupNode {
} }
return roots 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"})
}

View File

@@ -35,7 +35,7 @@ type Bot struct {
webAppURL string webAppURL string
// UI Elements (Menus) // UI Elements (Menus)
menuMain *tele.ReplyMarkup // menuMain удаляем как статическое поле, так как оно теперь динамическое
menuServers *tele.ReplyMarkup menuServers *tele.ReplyMarkup
menuDicts *tele.ReplyMarkup menuDicts *tele.ReplyMarkup
menuBalance *tele.ReplyMarkup menuBalance *tele.ReplyMarkup
@@ -89,21 +89,8 @@ func NewBot(
return bot, nil return bot, nil
} }
// initMenus инициализирует статические кнопки // initMenus инициализирует статические кнопки (кроме Главного меню)
func (bot *Bot) 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) --- // --- SERVERS MENU (Dynamic part logic is in handler) ---
bot.menuServers = &tele.ReplyMarkup{} bot.menuServers = &tele.ReplyMarkup{}
@@ -130,7 +117,10 @@ func (bot *Bot) initHandlers() {
bot.b.Use(bot.registrationMiddleware) bot.b.Use(bot.registrationMiddleware)
// Commands // Commands
bot.b.Handle("/start", bot.renderMainMenu) bot.b.Handle("/start", bot.handleStartCommand)
// Admin Commands
bot.b.Handle("/admin", bot.handleAdminCommand)
// Navigation Callbacks // Navigation Callbacks
bot.b.Handle(&tele.Btn{Unique: "nav_main"}, bot.renderMainMenu) 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") // 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) bot.b.Handle(tele.OnCallback, bot.handleCallback)
// Input Handlers // 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 { // Если есть payload, пробуем разобрать как приглашение
// Сбрасываем стейты FSM, если пользователь вернулся в меню if payload != "" && strings.HasPrefix(payload, "invite_") {
bot.fsm.Reset(c.Sender().ID) return bot.handleInviteLink(c, strings.TrimPrefix(payload, "invite_"))
}
txt := "👋 <b>Панель управления RMSER</b>\n\n" + return bot.renderMainMenu(c)
"Здесь вы можете управлять подключенными серверами iiko и следить за актуальностью справочников."
return c.EditOrSend(txt, bot.menuMain, tele.ModeHTML)
} }
func (bot *Bot) renderServersMenu(c tele.Context) error { // handleInviteLink обрабатывает приглашение пользователя на сервер
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID) func (bot *Bot) handleInviteLink(c tele.Context, serverIDStr string) error {
servers, err := bot.accountRepo.GetAllServers(userDB.ID) serverID, err := uuid.Parse(serverIDStr)
if err != nil { 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{} menu := &tele.ReplyMarkup{}
var rows []tele.Row 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 { for _, s := range servers {
icon := "🔴" icon := "🔴"
if s.IsActive { if activeServer != nil && activeServer.ID == s.ID {
icon = "🟢" 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)) rows = append(rows, menu.Row(btn))
} }
btnAdd := menu.Data(" Добавить сервер", "act_add_server") 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") btnBack := menu.Data("🔙 Назад", "nav_main")
rows = append(rows, menu.Row(btnAdd, btnDel)) rows = append(rows, menu.Row(btnAdd, btnDel))
@@ -228,7 +381,7 @@ func (bot *Bot) renderDictsMenu(c tele.Context) error {
var txt string var txt string
if err != nil { if err != nil {
txt = fmt.Sprintf("⚠️ <b>Статус:</b> Ошибка (%v)", err) txt = fmt.Sprintf("⚠️ <b>Статус:</b> Ошибка или нет активного сервера (%v)", err)
} else { } else {
lastUpdate := "—" lastUpdate := "—"
if stats.LastInvoice != nil { if stats.LastInvoice != nil {
@@ -255,7 +408,6 @@ func (bot *Bot) renderDictsMenu(c tele.Context) error {
} }
func (bot *Bot) renderBalanceMenu(c tele.Context) error { func (bot *Bot) renderBalanceMenu(c tele.Context) error {
// Заглушка баланса
txt := "<b>💰 Ваш баланс</b>\n\n" + txt := "<b>💰 Ваш баланс</b>\n\n" +
"💵 Текущий счет: <b>0.00 ₽</b>\n" + "💵 Текущий счет: <b>0.00 ₽</b>\n" +
"💎 Тариф: <b>Free</b>\n\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 { func (bot *Bot) handleCallback(c tele.Context) error {
data := c.Callback().Data data := c.Callback().Data
// FIX: Telebot v3 добавляет префикс '\f' к Unique ID кнопки.
// Нам нужно удалить его, чтобы корректно парсить строку.
if len(data) > 0 && data[0] == '\f' { if len(data) > 0 && data[0] == '\f' {
data = data[1:] data = data[1:]
} }
// Обработка выбора сервера "set_server_..." userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
// --- SELECT SERVER ---
if strings.HasPrefix(data, "set_server_") { if strings.HasPrefix(data, "set_server_") {
serverIDStr := strings.TrimPrefix(data, "set_server_") serverIDStr := strings.TrimPrefix(data, "set_server_")
serverIDStr = strings.TrimSpace(serverIDStr) serverIDStr = strings.TrimSpace(serverIDStr)
// Защита от старых форматов с разделителем |
if idx := strings.Index(serverIDStr, "|"); idx != -1 { if idx := strings.Index(serverIDStr, "|"); idx != -1 {
serverIDStr = serverIDStr[:idx] 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) targetID := parseUUID(serverIDStr)
if err := bot.accountRepo.SetActiveServer(userDB.ID, targetID); err != nil { if err := bot.accountRepo.SetActiveServer(userDB.ID, targetID); err != nil {
logger.Log.Error("Failed to set active server", zap.Error(err)) 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: "✅ Сервер выбран"}) 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_") { if strings.HasPrefix(data, "do_del_server_") {
serverIDStr := strings.TrimPrefix(data, "do_del_server_") serverIDStr := strings.TrimPrefix(data, "do_del_server_")
serverIDStr = strings.TrimSpace(serverIDStr) serverIDStr = strings.TrimSpace(serverIDStr)
// Очистка от мусора
if idx := strings.Index(serverIDStr, "|"); idx != -1 { if idx := strings.Index(serverIDStr, "|"); idx != -1 {
serverIDStr = serverIDStr[:idx] serverIDStr = serverIDStr[:idx]
} }
targetID := parseUUID(serverIDStr) 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 role == account.RoleOwner {
// Нам нужно знать это ДО удаления, чтобы переключить активность
// Но проще удалить, а потом проверить, остался ли активный сервер
// Удаляем
if err := bot.accountRepo.DeleteServer(targetID); err != nil { if err := bot.accountRepo.DeleteServer(targetID); err != nil {
logger.Log.Error("Failed to delete server", zap.Error(err))
return c.Respond(&tele.CallbackResponse{Text: "Ошибка удаления"}) return c.Respond(&tele.CallbackResponse{Text: "Ошибка удаления"})
} }
bot.rmsFactory.ClearCacheForUser(userDB.ID)
c.Respond(&tele.CallbackResponse{Text: "Сервер полностью удален"})
} else {
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)
bot.rmsFactory.ClearCache(targetID) if active == nil {
all, _ := bot.accountRepo.GetAllAvailableServers(userDB.ID)
// 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 { if len(all) > 0 {
// Делаем активным первый попавшийся
_ = bot.accountRepo.SetActiveServer(userDB.ID, all[0].ID) _ = bot.accountRepo.SetActiveServer(userDB.ID, all[0].ID)
c.Respond(&tele.CallbackResponse{Text: "Сервер удален. Активным назначен " + all[0].Name})
} else {
c.Respond(&tele.CallbackResponse{Text: "Сервер удален. Список пуст."})
} }
} else {
c.Respond(&tele.CallbackResponse{Text: "Сервер удален"})
} }
// Возвращаемся в меню удаления (обновляем список)
return bot.renderDeleteServerMenu(c) 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 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 { func (bot *Bot) triggerSync(c tele.Context) error {
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID) 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: "Запускаю синхронизацию..."}) c.Respond(&tele.CallbackResponse{Text: "Запускаю синхронизацию..."})
// Запускаем в фоне, но уведомляем юзера
go func() { go func() {
if err := bot.syncService.SyncAllData(userDB.ID); err != nil { if err := bot.syncService.SyncAllData(userDB.ID); err != nil {
logger.Log.Error("Manual sync failed", zap.Error(err)) 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 --- // --- FSM: ADD SERVER FLOW ---
func (bot *Bot) startAddServerFlow(c tele.Context) error { func (bot *Bot) startAddServerFlow(c tele.Context) error {
bot.fsm.SetState(c.Sender().ID, StateAddServerURL) 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 { 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) state := bot.fsm.GetState(userID)
text := strings.TrimSpace(c.Text()) text := strings.TrimSpace(c.Text())
// Глобальная отмена
if strings.ToLower(text) == "отмена" || strings.ToLower(text) == "/cancel" { if strings.ToLower(text) == "отмена" || strings.ToLower(text) == "/cancel" {
bot.fsm.Reset(userID) bot.fsm.Reset(userID)
return bot.renderMainMenu(c) return bot.renderMainMenu(c)
@@ -432,14 +677,12 @@ func (bot *Bot) handleText(c tele.Context) error {
ctx := bot.fsm.GetContext(userID) ctx := bot.fsm.GetContext(userID)
msg, _ := bot.b.Send(c.Sender(), "⏳ Проверяю подключение...") msg, _ := bot.b.Send(c.Sender(), "⏳ Проверяю подключение...")
// 1. Проверяем авторизацию (креды)
tempClient := bot.rmsFactory.CreateClientFromRawCredentials(ctx.TempURL, ctx.TempLogin, password) tempClient := bot.rmsFactory.CreateClientFromRawCredentials(ctx.TempURL, ctx.TempLogin, password)
if err := tempClient.Auth(); err != nil { if err := tempClient.Auth(); err != nil {
bot.b.Delete(msg) bot.b.Delete(msg)
return c.Send(fmt.Sprintf("❌ Ошибка авторизации: %v\nПроверьте логин/пароль.", err)) return c.Send(fmt.Sprintf("❌ Ошибка авторизации: %v\nПроверьте логин/пароль.", err))
} }
// 2. Пробуем узнать имя сервера
var detectedName string var detectedName string
info, err := rms.GetServerInfo(ctx.TempURL) info, err := rms.GetServerInfo(ctx.TempURL)
if err == nil && info.ServerName != "" { if err == nil && info.ServerName != "" {
@@ -448,30 +691,24 @@ func (bot *Bot) handleText(c tele.Context) error {
bot.b.Delete(msg) bot.b.Delete(msg)
// Сохраняем пароль во временный контекст, он нам пригодится при финальном сохранении
bot.fsm.UpdateContext(userID, func(uCtx *UserContext) { bot.fsm.UpdateContext(userID, func(uCtx *UserContext) {
uCtx.TempPassword = password uCtx.TempPassword = password
uCtx.TempServerName = detectedName uCtx.TempServerName = detectedName
}) })
// Если имя нашли - предлагаем выбор
if detectedName != "" { if detectedName != "" {
bot.fsm.SetState(userID, StateAddServerConfirmName) bot.fsm.SetState(userID, StateAddServerConfirmName)
menu := &tele.ReplyMarkup{} menu := &tele.ReplyMarkup{}
btnYes := menu.Data("✅ Да, использовать это имя", "confirm_name_yes") btnYes := menu.Data("✅ Да, использовать это имя", "confirm_name_yes")
btnNo := menu.Data("✏️ Ввести другое", "confirm_name_no") btnNo := menu.Data("✏️ Ввести другое", "confirm_name_no")
menu.Inline(menu.Row(btnYes), menu.Row(btnNo)) menu.Inline(menu.Row(btnYes), menu.Row(btnNo))
return c.Send(fmt.Sprintf("🔎 Обнаружено имя сервера: <b>%s</b>.\nИспользовать его?", detectedName), menu, tele.ModeHTML) return c.Send(fmt.Sprintf("🔎 Обнаружено имя сервера: <b>%s</b>.\nИспользовать его?", detectedName), menu, tele.ModeHTML)
} }
// Если имя не нашли - просим ввести вручную
bot.fsm.SetState(userID, StateAddServerInputName) bot.fsm.SetState(userID, StateAddServerInputName)
return c.Send("🏷 Введите <b>название</b> для этого сервера (для вашего удобства):") return c.Send("🏷 Введите <b>название</b> для этого сервера:")
case StateAddServerInputName: case StateAddServerInputName:
// Пользователь ввел свое название
name := text name := text
if len(name) < 3 { if len(name) < 3 {
return c.Send("⚠️ Название слишком короткое.") return c.Send("⚠️ Название слишком короткое.")
@@ -490,7 +727,7 @@ func (bot *Bot) handlePhoto(c tele.Context) error {
_, err = bot.rmsFactory.GetClientForUser(userDB.ID) _, err = bot.rmsFactory.GetClientForUser(userDB.ID)
if err != nil { if err != nil {
return c.Send("⛔ У вас не настроен сервер iiko.\nИспользуйте /add_server для настройки.") return c.Send("⛔ Ошибка доступа к iiko или сервер не выбран.\nПроверьте статус сервера в меню.")
} }
photo := c.Message().Photo photo := c.Message().Photo
@@ -540,17 +777,18 @@ func (bot *Bot) handlePhoto(c tele.Context) error {
} }
menu := &tele.ReplyMarkup{} 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)) menu.Inline(menu.Row(btnOpen))
} else {
msgText += "\n\n<i>(Редактирование доступно Администратору)</i>"
}
return c.Send(msgText, menu, tele.ModeHTML) 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 { func (bot *Bot) handleConfirmNameYes(c tele.Context) error {
userID := c.Sender().ID userID := c.Sender().ID
ctx := bot.fsm.GetContext(userID) ctx := bot.fsm.GetContext(userID)
@@ -566,40 +804,34 @@ func (bot *Bot) handleConfirmNameNo(c tele.Context) error {
return c.EditOrSend("🏷 Хорошо, введите желаемое <b>название</b>:") return c.EditOrSend("🏷 Хорошо, введите желаемое <b>название</b>:")
} }
// saveServerFinal - общая логика сохранения в БД
func (bot *Bot) saveServerFinal(c tele.Context, userID int64, serverName string) error { func (bot *Bot) saveServerFinal(c tele.Context, userID int64, serverName string) error {
ctx := bot.fsm.GetContext(userID) ctx := bot.fsm.GetContext(userID)
encPass, _ := bot.cryptoManager.Encrypt(ctx.TempPassword)
userDB, _ := bot.accountRepo.GetOrCreateUser(userID, c.Sender().Username, "", "") userDB, _ := bot.accountRepo.GetOrCreateUser(userID, c.Sender().Username, "", "")
newServer := &account.RMSServer{ encPass, _ := bot.cryptoManager.Encrypt(ctx.TempPassword)
UserID: userDB.ID,
Name: serverName, server, err := bot.accountRepo.ConnectServer(userDB.ID, ctx.TempURL, ctx.TempLogin, encPass, serverName)
BaseURL: ctx.TempURL, if err != nil {
Login: ctx.TempLogin, return c.Send("Ошибка подключения сервера: " + err.Error())
EncryptedPassword: encPass,
IsActive: true,
} }
if err := bot.accountRepo.SaveServer(newServer); err != nil {
return c.Send("Ошибка сохранения в БД: " + err.Error())
}
bot.accountRepo.SetActiveServer(userDB.ID, newServer.ID)
bot.fsm.Reset(userID) 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 if role == account.RoleOwner {
go bot.syncService.SyncAllData(userDB.ID) go bot.syncService.SyncAllData(userDB.ID)
} else {
go bot.syncService.SyncAllData(userDB.ID)
}
return bot.renderMainMenu(c) return bot.renderMainMenu(c)
} }
func (bot *Bot) renderDeleteServerMenu(c tele.Context) error { func (bot *Bot) renderDeleteServerMenu(c tele.Context) error {
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID) userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
servers, err := bot.accountRepo.GetAllServers(userDB.ID) servers, err := bot.accountRepo.GetAllAvailableServers(userDB.ID)
if err != nil { if err != nil {
return c.Send("Ошибка БД: " + err.Error()) return c.Send("Ошибка БД: " + err.Error())
} }
@@ -612,10 +844,23 @@ func (bot *Bot) renderDeleteServerMenu(c tele.Context) error {
var rows []tele.Row var rows []tele.Row
for _, s := range servers { for _, s := range servers {
// Кнопка удаления для каждого сервера role, _ := bot.accountRepo.GetUserRole(userDB.ID, s.ID)
// Префикс do_del_server_
btn := menu.Data(fmt.Sprintf("❌ %s", s.Name), "do_del_server_"+s.ID.String()) var label string
rows = append(rows, menu.Row(btn)) 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") btnBack := menu.Data("🔙 Назад к списку", "nav_servers")
@@ -623,5 +868,10 @@ func (bot *Bot) renderDeleteServerMenu(c tele.Context) error {
menu.Inline(rows...) 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
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,205 @@
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<Props> = ({ 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 (
<div style={{ textAlign: "center", padding: 20 }}>
<Spin />
</div>
);
}
if (isError) {
return <Alert type="error" message="Не удалось загрузить список команды" />;
}
return (
<>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="Приглашение сотрудников"
description="Чтобы добавить сотрудника, отправьте ему ссылку-приглашение. Ссылку можно сгенерировать в Telegram-боте в меню «Управление сервером»."
/>
<List
itemLayout="horizontal"
dataSource={users || []}
renderItem={(user) => (
<List.Item
actions={[
// Селектор роли (только для Владельца и не для себя)
currentUserRole === "OWNER" && !user.is_me ? (
<Select
key="role-select"
defaultValue={user.role}
size="small"
style={{ width: 110 }}
disabled={updateRoleMutation.isPending}
onChange={(val) =>
updateRoleMutation.mutate({
userId: user.user_id,
newRole: val,
})
}
options={[
{ value: "ADMIN", label: "Админ" },
{ value: "OPERATOR", label: "Оператор" },
]}
/>
) : (
<Tag key="role-tag" color={getRoleColor(user.role)}>
{getRoleName(user.role)}
</Tag>
),
// Кнопка удаления
<Popconfirm
key="delete"
title="Закрыть доступ?"
description={`Вы уверены, что хотите удалить ${user.first_name}?`}
onConfirm={() => removeUserMutation.mutate(user.user_id)}
disabled={!canDelete(user)}
okText="Да"
cancelText="Нет"
>
<Button
danger
type="text"
icon={<DeleteOutlined />}
disabled={!canDelete(user) || removeUserMutation.isPending}
/>
</Popconfirm>,
]}
>
<List.Item.Meta
avatar={
<Avatar src={user.photo_url} icon={<UserOutlined />}>
{user.first_name?.[0]}
</Avatar>
}
title={
<span>
{user.first_name} {user.last_name}{" "}
{user.is_me && <Text type="secondary">(Вы)</Text>}
</span>
}
description={
user.username ? (
<a
href={`https://t.me/${user.username}`}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: 12 }}
>
@{user.username}
</a>
) : (
<Text type="secondary" style={{ fontSize: 12 }}>
Нет username
</Text>
)
}
/>
</List.Item>
)}
/>
</>
);
};

View File

@@ -16,6 +16,7 @@ import {
Affix, Affix,
Modal, Modal,
Tag, Tag,
Image,
} from "antd"; } from "antd";
import { import {
ArrowLeftOutlined, ArrowLeftOutlined,
@@ -24,9 +25,10 @@ import {
ExclamationCircleFilled, ExclamationCircleFilled,
RestOutlined, RestOutlined,
PlusOutlined, PlusOutlined,
FileImageOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { api } from "../services/api"; import { api, getStaticUrl } from "../services/api";
import { DraftItemRow } from "../components/invoices/DraftItemRow"; import { DraftItemRow } from "../components/invoices/DraftItemRow";
import type { import type {
UpdateDraftItemRequest, UpdateDraftItemRequest,
@@ -45,6 +47,9 @@ export const InvoiceDraftPage: React.FC = () => {
const [updatingItems, setUpdatingItems] = useState<Set<string>>(new Set()); const [updatingItems, setUpdatingItems] = useState<Set<string>>(new Set());
// Состояние для просмотра фото чека
const [previewVisible, setPreviewVisible] = useState(false);
// --- ЗАПРОСЫ --- // --- ЗАПРОСЫ ---
const dictQuery = useQuery({ const dictQuery = useQuery({
@@ -95,20 +100,17 @@ export const InvoiceDraftPage: React.FC = () => {
}, },
}); });
// ДОБАВЛЕНО: Добавление строки
const addItemMutation = useMutation({ const addItemMutation = useMutation({
mutationFn: () => api.addDraftItem(id!), mutationFn: () => api.addDraftItem(id!),
onSuccess: () => { onSuccess: () => {
message.success("Строка добавлена"); message.success("Строка добавлена");
queryClient.invalidateQueries({ queryKey: ["draft", id] }); queryClient.invalidateQueries({ queryKey: ["draft", id] });
// Можно сделать скролл вниз, но пока оставим как есть
}, },
onError: () => { onError: () => {
message.error("Ошибка создания строки"); message.error("Ошибка создания строки");
}, },
}); });
// ДОБАВЛЕНО: Удаление строки
const deleteItemMutation = useMutation({ const deleteItemMutation = useMutation({
mutationFn: (itemId: string) => api.deleteDraftItem(id!, itemId), mutationFn: (itemId: string) => api.deleteDraftItem(id!, itemId),
onSuccess: () => { onSuccess: () => {
@@ -293,6 +295,19 @@ export const InvoiceDraftPage: React.FC = () => {
</div> </div>
</div> </div>
{/* Правая часть хедера: Кнопка чека и Кнопка удаления */}
<div style={{ display: "flex", gap: 8 }}>
{/* Кнопка просмотра чека (только если есть URL) */}
{draft.photo_url && (
<Button
icon={<FileImageOutlined />}
onClick={() => setPreviewVisible(true)}
size="small"
>
Чек
</Button>
)}
<Button <Button
danger={isCanceled} danger={isCanceled}
type={isCanceled ? "primary" : "default"} type={isCanceled ? "primary" : "default"}
@@ -304,6 +319,7 @@ export const InvoiceDraftPage: React.FC = () => {
{isCanceled ? "Удалить" : "Отмена"} {isCanceled ? "Удалить" : "Отмена"}
</Button> </Button>
</div> </div>
</div>
{/* Form: Склады и Поставщики */} {/* Form: Склады и Поставщики */}
<div <div
@@ -422,7 +438,7 @@ export const InvoiceDraftPage: React.FC = () => {
type="dashed" type="dashed"
block block
icon={<PlusOutlined />} icon={<PlusOutlined />}
style={{ marginTop: 12, marginBottom: 80, height: 48 }} // Увеличенный margin bottom для Affix style={{ marginTop: 12, marginBottom: 80, height: 48 }}
onClick={() => addItemMutation.mutate()} onClick={() => addItemMutation.mutate()}
loading={addItemMutation.isPending} loading={addItemMutation.isPending}
disabled={isCanceled} disabled={isCanceled}
@@ -476,6 +492,22 @@ export const InvoiceDraftPage: React.FC = () => {
</Button> </Button>
</div> </div>
</Affix> </Affix>
{/* Скрытый компонент для просмотра изображения */}
{draft.photo_url && (
<div style={{ display: "none" }}>
<Image.PreviewGroup
preview={{
visible: previewVisible,
onVisibleChange: (vis) => setPreviewVisible(vis),
movable: true,
scaleStep: 0.5,
}}
>
<Image src={getStaticUrl(draft.photo_url)} />
</Image.PreviewGroup>
</div>
)}
</div> </div>
); );
}; };

View File

@@ -12,16 +12,19 @@ import {
TreeSelect, TreeSelect,
Spin, Spin,
message, message,
Tabs,
} from "antd"; } from "antd";
import { import {
SaveOutlined, SaveOutlined,
BarChartOutlined, BarChartOutlined,
SettingOutlined, SettingOutlined,
FolderOpenOutlined, FolderOpenOutlined,
TeamOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../services/api"; import { api } from "../services/api";
import type { UserSettings } from "../services/types"; import type { UserSettings } from "../services/types";
import { TeamList } from "../components/settings/TeamList";
const { Title, Text } = Typography; const { Title, Text } = Typography;
@@ -95,6 +98,115 @@ export const SettingsPage: React.FC = () => {
); );
} }
// Определяем роль текущего пользователя
const currentUserRole = settingsQuery.data?.role || "OPERATOR";
const showTeamSettings =
currentUserRole === "ADMIN" || currentUserRole === "OWNER";
// Сохраняем JSX в переменную вместо создания вложенного компонента
const generalSettingsContent = (
<Form form={form} layout="vertical">
<Card size="small" style={{ marginBottom: 16 }}>
<Form.Item
label="Склад по умолчанию"
name="default_store_id"
tooltip="Этот склад будет выбираться автоматически при создании новой накладной"
>
<Select
placeholder="Не выбрано"
allowClear
loading={dictQuery.isLoading}
options={dictQuery.data?.stores.map((s) => ({
label: s.name,
value: s.id,
}))}
/>
</Form.Item>
<Form.Item
label="Корневая группа товаров"
name="root_group_id"
tooltip="Товары для распознавания будут искаться только внутри этой группы (и её подгрупп)."
>
<TreeSelect
showSearch
style={{ width: "100%" }}
dropdownStyle={{ maxHeight: 400, overflow: "auto" }}
placeholder="Выберите папку"
allowClear
treeDefaultExpandAll={false}
treeData={groupsQuery.data}
fieldNames={{
label: "title",
value: "value",
children: "children",
}}
treeNodeFilterProp="title"
suffixIcon={<FolderOpenOutlined />}
loading={groupsQuery.isLoading}
/>
</Form.Item>
<Form.Item
name="auto_conduct"
valuePropName="checked"
style={{ marginBottom: 0 }}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div>
<Text>Проводить накладные автоматически</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
Если выключено, накладные в iiko будут создаваться как
"Непроведенные"
</Text>
</div>
<Switch />
</div>
</Form.Item>
</Card>
<Button
type="primary"
icon={<SaveOutlined />}
block
size="large"
onClick={handleSave}
loading={saveMutation.isPending}
>
Сохранить настройки
</Button>
</Form>
);
const tabsItems = [
{
key: "general",
label: "Общие",
icon: <SettingOutlined />,
children: generalSettingsContent,
},
];
if (showTeamSettings) {
tabsItems.push({
key: "team",
label: "Команда",
icon: <TeamOutlined />,
children: (
<Card size="small">
<TeamList currentUserRole={currentUserRole} />
</Card>
),
});
}
return ( return (
<div style={{ padding: "0 16px 80px" }}> <div style={{ padding: "0 16px 80px" }}>
<Title level={4} style={{ marginTop: 16 }}> <Title level={4} style={{ marginTop: 16 }}>
@@ -146,90 +258,8 @@ export const SettingsPage: React.FC = () => {
</Row> </Row>
</Card> </Card>
{/* Форма настроек */} {/* Табы настроек */}
<Form form={form} layout="vertical"> <Tabs defaultActiveKey="general" items={tabsItems} />
<Card
size="small"
title="Основные параметры"
style={{ marginBottom: 16 }}
>
<Form.Item
label="Склад по умолчанию"
name="default_store_id"
tooltip="Этот склад будет выбираться автоматически при создании новой накладной"
>
<Select
placeholder="Не выбрано"
allowClear
loading={dictQuery.isLoading}
options={dictQuery.data?.stores.map((s) => ({
label: s.name,
value: s.id,
}))}
/>
</Form.Item>
<Form.Item
label="Корневая группа товаров"
name="root_group_id"
tooltip="Товары для распознавания будут искаться только внутри этой группы (и её подгрупп)."
>
<TreeSelect
showSearch
style={{ width: "100%" }}
dropdownStyle={{ maxHeight: 400, overflow: "auto" }}
placeholder="Выберите папку"
allowClear
treeDefaultExpandAll={false}
treeData={groupsQuery.data}
// ИСПРАВЛЕНО: Маппинг полей под структуру JSON (title, value)
fieldNames={{
label: "title",
value: "value",
children: "children",
}}
treeNodeFilterProp="title"
suffixIcon={<FolderOpenOutlined />}
loading={groupsQuery.isLoading}
/>
</Form.Item>
<Form.Item
name="auto_conduct"
valuePropName="checked"
style={{ marginBottom: 0 }}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div>
<Text>Проводить накладные автоматически</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
Если выключено, накладные в iiko будут создаваться как
"Непроведенные"
</Text>
</div>
<Switch />
</div>
</Form.Item>
</Card>
<Button
type="primary"
icon={<SaveOutlined />}
block
size="large"
onClick={handleSave}
loading={saveMutation.isPending}
>
Сохранить настройки
</Button>
</Form>
</div> </div>
); );
}; };

View File

@@ -22,11 +22,21 @@ import type {
AddContainerRequest, AddContainerRequest,
AddContainerResponse, AddContainerResponse,
DictionariesResponse, DictionariesResponse,
DraftSummary DraftSummary,
ServerUser,
UserRole
} from './types'; } from './types';
// Базовый URL // Базовый 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; const tg = window.Telegram?.WebApp;
@@ -35,7 +45,7 @@ const tg = window.Telegram?.WebApp;
export const UNAUTHORIZED_EVENT = 'rms_unauthorized'; export const UNAUTHORIZED_EVENT = 'rms_unauthorized';
const apiClient = axios.create({ const apiClient = axios.create({
baseURL: API_URL, baseURL: API_BASE_URL,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
@@ -203,4 +213,21 @@ export const api = {
const { data } = await apiClient.get<ProductGroup[]>('/dictionaries/groups'); const { data } = await apiClient.get<ProductGroup[]>('/dictionaries/groups');
return data; return data;
}, },
// --- Управление командой ---
getUsers: async (): Promise<ServerUser[]> => {
const { data } = await apiClient.get<ServerUser[]>('/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;
},
}; };

View File

@@ -2,6 +2,20 @@
export type UUID = string; 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) --- // --- Каталог и Фасовки (API v2.0) ---
export interface ProductContainer { export interface ProductContainer {
@@ -133,6 +147,7 @@ export interface UserSettings {
root_group_id: UUID | null; root_group_id: UUID | null;
default_store_id: UUID | null; default_store_id: UUID | null;
auto_conduct: boolean; auto_conduct: boolean;
role: UserRole; // Добавляем поле роли в настройки текущего пользователя
} }
export interface InvoiceStats { export interface InvoiceStats {
@@ -196,6 +211,7 @@ export interface DraftInvoice {
comment: string; comment: string;
items: DraftItem[]; items: DraftItem[];
created_at?: string; created_at?: string;
photo_url?: string; // Добавлено поле фото чека
} }
// DTO для обновления строки // DTO для обновления строки