mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
добавил пользователей для сервера и роли
добавил инвайт-ссылки с ролью оператор для сервера добавил супер-админку для смены владельцев добавил уведомления о смене ролей на серверах добавил модалку для фото прям в черновике добавил UI для редактирования прав
This commit is contained in:
@@ -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"]
|
||||||
16
cmd/main.go
16
cmd/main.go
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -18,9 +18,11 @@ type Config struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AppConfig struct {
|
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 {
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{},
|
||||||
|
|||||||
@@ -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(¤tCount)
|
||||||
|
if currentCount >= int64(server.MaxUsers) {
|
||||||
|
return fmt.Errorf("лимит пользователей (%d) превышен", server.MaxUsers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сбрасываем активность на других серверах
|
||||||
|
if err := tx.Model(&account.ServerUser{}).Where("user_id = ?", userID).Update("is_active", false).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем связь
|
||||||
|
link := account.ServerUser{
|
||||||
|
ServerID: serverID,
|
||||||
|
UserID: userID,
|
||||||
|
Role: role,
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
return tx.Create(&link).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgRepository) RemoveUserFromServer(serverID, userID uuid.UUID) error {
|
||||||
|
return r.db.Where("server_id = ? AND user_id = ?", serverID, userID).Delete(&account.ServerUser{}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *pgRepository) IncrementInvoiceCount(serverID uuid.UUID) error {
|
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(¤tOwnerLink).Error; err != nil {
|
||||||
|
return fmt.Errorf("current owner not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Проверяем, что новый владелец вообще есть на сервере
|
||||||
|
var newOwnerLink account.ServerUser
|
||||||
|
if err := tx.Where("server_id = ? AND user_id = ?", serverID, newOwnerID).First(&newOwnerLink).Error; err != nil {
|
||||||
|
return fmt.Errorf("target user not found on server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Понижаем старого владельца до ADMIN
|
||||||
|
if err := tx.Model(¤tOwnerLink).Update("role", account.RoleAdmin).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Повышаем нового до OWNER
|
||||||
|
if err := tx.Model(&newOwnerLink).Update("role", account.RoleOwner).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// УДАЛЕНО: обновление server.owner_id, так как этого поля нет в модели
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgRepository) GetConnectionByID(id uuid.UUID) (*account.ServerUser, error) {
|
||||||
|
var link account.ServerUser
|
||||||
|
// Preload нужны, чтобы показать имена в админке
|
||||||
|
err := r.db.Preload("User").Preload("Server").Where("id = ?", id).First(&link).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &link, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ func (r *pgRepository) GetByID(id uuid.UUID) (*drafts.DraftInvoice, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *pgRepository) Update(draft *drafts.DraftInvoice) error {
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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 все равно протухнут.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
// Но проще удалить, а потом проверить, остался ли активный сервер
|
return c.Respond(&tele.CallbackResponse{Text: "Ошибка удаления"})
|
||||||
|
|
||||||
// Удаляем
|
|
||||||
if err := bot.accountRepo.DeleteServer(targetID); err != nil {
|
|
||||||
logger.Log.Error("Failed to delete server", zap.Error(err))
|
|
||||||
return c.Respond(&tele.CallbackResponse{Text: "Ошибка удаления"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Сбрасываем кэш клиента в фабрике
|
|
||||||
bot.rmsFactory.ClearCache(targetID)
|
|
||||||
|
|
||||||
// 2. Проверяем, есть ли активный сервер у пользователя
|
|
||||||
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
|
|
||||||
active, err := bot.accountRepo.GetActiveServer(userDB.ID)
|
|
||||||
|
|
||||||
// Если активного нет (мы удалили активный) или ошибка - назначаем новый
|
|
||||||
if active == nil || err != nil {
|
|
||||||
all, _ := bot.accountRepo.GetAllServers(userDB.ID)
|
|
||||||
if len(all) > 0 {
|
|
||||||
// Делаем активным первый попавшийся
|
|
||||||
_ = bot.accountRepo.SetActiveServer(userDB.ID, all[0].ID)
|
|
||||||
c.Respond(&tele.CallbackResponse{Text: "Сервер удален. Активным назначен " + all[0].Name})
|
|
||||||
} else {
|
|
||||||
c.Respond(&tele.CallbackResponse{Text: "Сервер удален. Список пуст."})
|
|
||||||
}
|
}
|
||||||
|
bot.rmsFactory.ClearCacheForUser(userDB.ID)
|
||||||
|
c.Respond(&tele.CallbackResponse{Text: "Сервер полностью удален"})
|
||||||
} else {
|
} else {
|
||||||
c.Respond(&tele.CallbackResponse{Text: "Сервер удален"})
|
if err := bot.accountRepo.RemoveUserFromServer(targetID, userDB.ID); err != nil {
|
||||||
|
return c.Respond(&tele.CallbackResponse{Text: "Ошибка выхода"})
|
||||||
|
}
|
||||||
|
bot.rmsFactory.ClearCacheForUser(userDB.ID)
|
||||||
|
c.Respond(&tele.CallbackResponse{Text: "Вы покинули сервер"})
|
||||||
|
}
|
||||||
|
|
||||||
|
active, _ := bot.accountRepo.GetActiveServer(userDB.ID)
|
||||||
|
if active == nil {
|
||||||
|
all, _ := bot.accountRepo.GetAllAvailableServers(userDB.ID)
|
||||||
|
if len(all) > 0 {
|
||||||
|
_ = bot.accountRepo.SetActiveServer(userDB.ID, all[0].ID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Возвращаемся в меню удаления (обновляем список)
|
|
||||||
return bot.renderDeleteServerMenu(c)
|
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{}
|
||||||
btnOpen := menu.WebApp("📝 Открыть накладную", &tele.WebApp{URL: fullURL})
|
|
||||||
menu.Inline(menu.Row(btnOpen))
|
role, _ := bot.accountRepo.GetUserRole(userDB.ID, draft.RMSServerID)
|
||||||
|
if role != account.RoleOperator {
|
||||||
|
btnOpen := menu.WebApp("📝 Открыть накладную", &tele.WebApp{URL: fullURL})
|
||||||
|
menu.Inline(menu.Row(btnOpen))
|
||||||
|
} else {
|
||||||
|
msgText += "\n\n<i>(Редактирование доступно Администратору)</i>"
|
||||||
|
}
|
||||||
|
|
||||||
return c.Send(msgText, menu, tele.ModeHTML)
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
7588
rmser-view/project_context.md
Normal file
7588
rmser-view/project_context.md
Normal file
File diff suppressed because it is too large
Load Diff
205
rmser-view/src/components/settings/TeamList.tsx
Normal file
205
rmser-view/src/components/settings/TeamList.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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,16 +295,30 @@ export const InvoiceDraftPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
{/* Правая часть хедера: Кнопка чека и Кнопка удаления */}
|
||||||
danger={isCanceled}
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
type={isCanceled ? "primary" : "default"}
|
{/* Кнопка просмотра чека (только если есть URL) */}
|
||||||
icon={isCanceled ? <DeleteOutlined /> : <RestOutlined />}
|
{draft.photo_url && (
|
||||||
onClick={handleDelete}
|
<Button
|
||||||
loading={deleteDraftMutation.isPending}
|
icon={<FileImageOutlined />}
|
||||||
size="small"
|
onClick={() => setPreviewVisible(true)}
|
||||||
>
|
size="small"
|
||||||
{isCanceled ? "Удалить" : "Отмена"}
|
>
|
||||||
</Button>
|
Чек
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
danger={isCanceled}
|
||||||
|
type={isCanceled ? "primary" : "default"}
|
||||||
|
icon={isCanceled ? <DeleteOutlined /> : <RestOutlined />}
|
||||||
|
onClick={handleDelete}
|
||||||
|
loading={deleteDraftMutation.isPending}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{isCanceled ? "Удалить" : "Отмена"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Form: Склады и Поставщики */}
|
{/* Form: Склады и Поставщики */}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
@@ -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 для обновления строки
|
||||||
|
|||||||
Reference in New Issue
Block a user