mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
Настройки работают
Иерархия групп работает Полностью завязано на пользователя и серверы
This commit is contained in:
@@ -36,6 +36,10 @@ type RMSServer struct {
|
||||
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 корневой папки для поиска товаров
|
||||
AutoProcess bool `gorm:"default:false" json:"auto_process"` // Пытаться сразу проводить накладную
|
||||
|
||||
// Billing / Stats
|
||||
InvoiceCount int `gorm:"default:0" json:"invoice_count"` // Счетчик успешно отправленных накладных
|
||||
|
||||
|
||||
@@ -58,8 +58,10 @@ type Repository interface {
|
||||
SaveProducts(products []Product) error
|
||||
SaveContainer(container ProductContainer) error
|
||||
|
||||
Search(serverID uuid.UUID, query string) ([]Product, error)
|
||||
GetActiveGoods(serverID uuid.UUID) ([]Product, error)
|
||||
Search(serverID uuid.UUID, query string, rootGroupID *uuid.UUID) ([]Product, error)
|
||||
GetActiveGoods(serverID uuid.UUID, rootGroupID *uuid.UUID) ([]Product, error)
|
||||
|
||||
GetGroups(serverID uuid.UUID) ([]Product, error)
|
||||
|
||||
SaveStores(stores []Store) error
|
||||
GetActiveStores(serverID uuid.UUID) ([]Store, error)
|
||||
|
||||
@@ -70,6 +70,8 @@ type Repository interface {
|
||||
Update(draft *DraftInvoice) error
|
||||
CreateItems(items []DraftInvoiceItem) error
|
||||
UpdateItem(itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error
|
||||
CreateItem(item *DraftInvoiceItem) error
|
||||
DeleteItem(itemID uuid.UUID) error
|
||||
Delete(id uuid.UUID) error
|
||||
GetActive(userID uuid.UUID) ([]DraftInvoice, error)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ type Invoice struct {
|
||||
SupplierID uuid.UUID `gorm:"type:uuid;index"`
|
||||
DefaultStoreID uuid.UUID `gorm:"type:uuid;index"`
|
||||
Status string `gorm:"type:varchar(50)"`
|
||||
Comment string `gorm:"type:text"`
|
||||
|
||||
Items []InvoiceItem `gorm:"foreignKey:InvoiceID;constraint:OnDelete:CASCADE"`
|
||||
|
||||
|
||||
@@ -100,9 +100,11 @@ func (r *pgRepository) GetActiveServer(userID uuid.UUID) (*account.RMSServer, er
|
||||
return &server, nil
|
||||
}
|
||||
|
||||
// GetAllServers возвращает ВСЕ серверы пользователя, чтобы можно было переключаться
|
||||
func (r *pgRepository) GetAllServers(userID uuid.UUID) ([]account.RMSServer, error) {
|
||||
var servers []account.RMSServer
|
||||
err := r.db.Where("user_id = ? AND is_active = ?", userID, true).Find(&servers).Error
|
||||
// Убрали фильтр AND is_active = true, теперь возвращает весь список
|
||||
err := r.db.Where("user_id = ?", userID).Find(&servers).Error
|
||||
return servers, err
|
||||
}
|
||||
|
||||
|
||||
@@ -86,14 +86,25 @@ func (r *pgRepository) GetAll() ([]catalog.Product, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) GetActiveGoods(serverID uuid.UUID) ([]catalog.Product, error) {
|
||||
func (r *pgRepository) GetActiveGoods(serverID uuid.UUID, rootGroupID *uuid.UUID) ([]catalog.Product, error) {
|
||||
var products []catalog.Product
|
||||
err := r.db.
|
||||
Preload("MainUnit").
|
||||
Preload("Containers").
|
||||
Where("rms_server_id = ? AND is_deleted = ? AND type IN ?", serverID, false, []string{"GOODS"}).
|
||||
Order("name ASC").
|
||||
Find(&products).Error
|
||||
db := r.db.Preload("MainUnit").Preload("Containers").
|
||||
Where("rms_server_id = ? AND is_deleted = ? AND type IN ?", serverID, false, []string{"GOODS"})
|
||||
|
||||
if rootGroupID != nil && *rootGroupID != uuid.Nil {
|
||||
// Используем Recursive CTE для поиска всех дочерних элементов папки
|
||||
subQuery := r.db.Raw(`
|
||||
WITH RECURSIVE subnodes AS (
|
||||
SELECT id FROM products WHERE id = ?
|
||||
UNION ALL
|
||||
SELECT p.id FROM products p INNER JOIN subnodes s ON p.parent_id = s.id
|
||||
)
|
||||
SELECT id FROM subnodes
|
||||
`, rootGroupID)
|
||||
db = db.Where("id IN (?)", subQuery)
|
||||
}
|
||||
|
||||
err := db.Order("name ASC").Find(&products).Error
|
||||
return products, err
|
||||
}
|
||||
|
||||
@@ -103,19 +114,27 @@ func (r *pgRepository) GetActiveStores(serverID uuid.UUID) ([]catalog.Store, err
|
||||
return stores, err
|
||||
}
|
||||
|
||||
func (r *pgRepository) Search(serverID uuid.UUID, query string) ([]catalog.Product, error) {
|
||||
func (r *pgRepository) Search(serverID uuid.UUID, query string, rootGroupID *uuid.UUID) ([]catalog.Product, error) {
|
||||
var products []catalog.Product
|
||||
q := "%" + query + "%"
|
||||
|
||||
err := r.db.
|
||||
Preload("MainUnit").
|
||||
Preload("Containers").
|
||||
db := r.db.Preload("MainUnit").Preload("Containers").
|
||||
Where("rms_server_id = ? AND is_deleted = ? AND type = ?", serverID, false, "GOODS").
|
||||
Where("name ILIKE ? OR code ILIKE ? OR num ILIKE ?", q, q, q).
|
||||
Order("name ASC").
|
||||
Limit(20).
|
||||
Find(&products).Error
|
||||
Where("(name ILIKE ? OR code ILIKE ? OR num ILIKE ?)", q, q, q)
|
||||
|
||||
if rootGroupID != nil && *rootGroupID != uuid.Nil {
|
||||
subQuery := r.db.Raw(`
|
||||
WITH RECURSIVE subnodes AS (
|
||||
SELECT id FROM products WHERE id = ?
|
||||
UNION ALL
|
||||
SELECT p.id FROM products p INNER JOIN subnodes s ON p.parent_id = s.id
|
||||
)
|
||||
SELECT id FROM subnodes
|
||||
`, rootGroupID)
|
||||
db = db.Where("id IN (?)", subQuery)
|
||||
}
|
||||
|
||||
err := db.Order("name ASC").Limit(20).Find(&products).Error
|
||||
return products, err
|
||||
}
|
||||
|
||||
@@ -176,3 +195,12 @@ func (r *pgRepository) CountStores(serverID uuid.UUID) (int64, error) {
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *pgRepository) GetGroups(serverID uuid.UUID) ([]catalog.Product, error) {
|
||||
var groups []catalog.Product
|
||||
// iiko присылает группы с типом "GROUP"
|
||||
err := r.db.Where("rms_server_id = ? AND type = ? AND is_deleted = ?", serverID, "GROUP", false).
|
||||
Order("name ASC").
|
||||
Find(&groups).Error
|
||||
return groups, err
|
||||
}
|
||||
|
||||
@@ -60,6 +60,14 @@ func (r *pgRepository) CreateItems(items []drafts.DraftInvoiceItem) error {
|
||||
return r.db.CreateInBatches(items, 100).Error
|
||||
}
|
||||
|
||||
func (r *pgRepository) CreateItem(item *drafts.DraftInvoiceItem) error {
|
||||
return r.db.Create(item).Error
|
||||
}
|
||||
|
||||
func (r *pgRepository) DeleteItem(itemID uuid.UUID) error {
|
||||
return r.db.Delete(&drafts.DraftInvoiceItem{}, itemID).Error
|
||||
}
|
||||
|
||||
func (r *pgRepository) UpdateItem(itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error {
|
||||
sum := qty.Mul(price)
|
||||
isMatched := productID != nil
|
||||
|
||||
@@ -557,13 +557,26 @@ func (c *Client) FetchStoreOperations(presetID string, from, to time.Time) ([]St
|
||||
// CreateIncomingInvoice отправляет накладную в iiko
|
||||
func (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) {
|
||||
// 1. Маппинг Domain -> XML DTO
|
||||
|
||||
// Статус по умолчанию NEW, если не передан
|
||||
status := inv.Status
|
||||
if status == "" {
|
||||
status = "NEW"
|
||||
}
|
||||
|
||||
// Комментарий по умолчанию, если пустой
|
||||
comment := inv.Comment
|
||||
if comment == "" {
|
||||
comment = "Loaded via RMSER OCR"
|
||||
}
|
||||
|
||||
reqDTO := IncomingInvoiceImportXML{
|
||||
DocumentNumber: inv.DocumentNumber,
|
||||
DateIncoming: inv.DateIncoming.Format("02.01.2006"),
|
||||
DefaultStore: inv.DefaultStoreID.String(),
|
||||
Supplier: inv.SupplierID.String(),
|
||||
Status: "NEW",
|
||||
Comment: "Loaded via RMSER OCR",
|
||||
Status: status,
|
||||
Comment: comment,
|
||||
}
|
||||
|
||||
if inv.ID != uuid.Nil {
|
||||
@@ -758,6 +771,49 @@ func (c *Client) UpdateProduct(product ProductFullDTO) (*ProductFullDTO, error)
|
||||
return result.Response, nil
|
||||
}
|
||||
|
||||
// GetServerInfo пытается получить информацию о сервере (имя, версия) без авторизации.
|
||||
// Использует endpoint /resto/getServerMonitoringInfo.jsp
|
||||
func GetServerInfo(baseURL string) (*ServerMonitoringInfoDTO, error) {
|
||||
// Формируем URL. Убираем слэш в конце, если есть.
|
||||
url := strings.TrimRight(baseURL, "/") + "/resto/getServerMonitoringInfo.jsp"
|
||||
|
||||
logger.Log.Info("RMS: Requesting server info", zap.String("url", url))
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
logger.Log.Error("RMS: Monitoring connection failed", zap.Error(err))
|
||||
return nil, fmt.Errorf("connection error: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read body error: %w", err)
|
||||
}
|
||||
|
||||
logger.Log.Info("RMS: Monitoring Response",
|
||||
zap.Int("status", resp.StatusCode),
|
||||
zap.String("body", string(bodyBytes)))
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("status code %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var info ServerMonitoringInfoDTO
|
||||
|
||||
// Пробуем JSON (так как в логе пришел JSON)
|
||||
if err := json.Unmarshal(bodyBytes, &info); err != nil {
|
||||
// Если вдруг JSON не прошел, можно попробовать XML как фоллбек (для старых версий)
|
||||
logger.Log.Warn("RMS: JSON decode failed, trying XML...", zap.Error(err))
|
||||
if xmlErr := xml.Unmarshal(bodyBytes, &info); xmlErr != nil {
|
||||
return nil, fmt.Errorf("decode error (json & xml failed): %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// FetchSuppliers загружает список поставщиков через XML API
|
||||
func (c *Client) FetchSuppliers() ([]suppliers.Supplier, error) {
|
||||
// Endpoint /resto/api/suppliers
|
||||
|
||||
@@ -244,6 +244,13 @@ type ErrorDTO struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// ServerMonitoringInfoDTO используется для парсинга ответа мониторинга
|
||||
// iiko может отдавать JSON, поэтому ставим json теги.
|
||||
type ServerMonitoringInfoDTO struct {
|
||||
ServerName string `json:"serverName" xml:"serverName"`
|
||||
Version string `json:"version" xml:"version"`
|
||||
}
|
||||
|
||||
// --- Suppliers XML (Legacy API /resto/api/suppliers) ---
|
||||
|
||||
type SuppliersListXML struct {
|
||||
|
||||
@@ -108,6 +108,56 @@ func (s *Service) UpdateDraftHeader(id uuid.UUID, storeID *uuid.UUID, supplierID
|
||||
return s.draftRepo.Update(draft)
|
||||
}
|
||||
|
||||
// AddItem добавляет пустую строку в черновик
|
||||
func (s *Service) AddItem(draftID uuid.UUID) (*drafts.DraftInvoiceItem, error) {
|
||||
// Проверка статуса драфта (можно добавить)
|
||||
|
||||
newItem := &drafts.DraftInvoiceItem{
|
||||
ID: uuid.New(),
|
||||
DraftID: draftID,
|
||||
RawName: "Новая позиция",
|
||||
RawAmount: decimal.NewFromFloat(1),
|
||||
RawPrice: decimal.Zero,
|
||||
Quantity: decimal.NewFromFloat(1),
|
||||
Price: decimal.Zero,
|
||||
Sum: decimal.Zero,
|
||||
IsMatched: false,
|
||||
}
|
||||
|
||||
if err := s.draftRepo.CreateItem(newItem); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newItem, nil
|
||||
}
|
||||
|
||||
// DeleteItem удаляет строку и возвращает обновленную сумму черновика
|
||||
func (s *Service) DeleteItem(draftID, itemID uuid.UUID) (float64, error) {
|
||||
// 1. Удаляем
|
||||
if err := s.draftRepo.DeleteItem(itemID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// 2. Получаем драфт заново для пересчета суммы
|
||||
// Это самый надежный способ, чем считать в памяти
|
||||
draft, err := s.draftRepo.GetByID(draftID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// 3. Считаем сумму
|
||||
var totalSum decimal.Decimal
|
||||
for _, item := range draft.Items {
|
||||
if !item.Sum.IsZero() {
|
||||
totalSum = totalSum.Add(item.Sum)
|
||||
} else {
|
||||
totalSum = totalSum.Add(item.Quantity.Mul(item.Price))
|
||||
}
|
||||
}
|
||||
|
||||
sumFloat, _ := totalSum.Float64()
|
||||
return sumFloat, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateItem(draftID, itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error {
|
||||
draft, err := s.draftRepo.GetByID(draftID)
|
||||
if err != nil {
|
||||
@@ -137,6 +187,16 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) {
|
||||
return "", errors.New("накладная уже отправлена")
|
||||
}
|
||||
|
||||
server, err := s.accountRepo.GetActiveServer(userID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("active server not found: %w", err)
|
||||
}
|
||||
|
||||
targetStatus := "NEW"
|
||||
if server.AutoProcess {
|
||||
targetStatus = "PROCESSED"
|
||||
}
|
||||
|
||||
// 3. Сборка Invoice
|
||||
inv := invoices.Invoice{
|
||||
ID: uuid.Nil,
|
||||
@@ -144,7 +204,8 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) {
|
||||
DateIncoming: *draft.DateIncoming,
|
||||
SupplierID: *draft.SupplierID,
|
||||
DefaultStoreID: *draft.StoreID,
|
||||
Status: "NEW",
|
||||
Status: targetStatus, // <-- Передаем статус из настроек
|
||||
Comment: draft.Comment, // <-- Передаем комментарий из черновика
|
||||
Items: make([]invoices.InvoiceItem, 0, len(draft.Items)),
|
||||
}
|
||||
|
||||
@@ -179,19 +240,15 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 5. Обновление статуса
|
||||
// 5. Обновление статуса черновика
|
||||
draft.Status = drafts.StatusCompleted
|
||||
s.draftRepo.Update(draft)
|
||||
|
||||
// 6. БИЛЛИНГ: Увеличиваем счетчик накладных
|
||||
server, _ := s.accountRepo.GetActiveServer(userID)
|
||||
if server != nil {
|
||||
if err := s.accountRepo.IncrementInvoiceCount(server.ID); err != nil {
|
||||
logger.Log.Error("Billing increment failed", zap.Error(err))
|
||||
}
|
||||
// 7. Обучение (передаем ID сервера для сохранения маппинга)
|
||||
go s.learnFromDraft(draft, server.ID)
|
||||
// 6. БИЛЛИНГ и Обучение
|
||||
if err := s.accountRepo.IncrementInvoiceCount(server.ID); err != nil {
|
||||
logger.Log.Error("Billing increment failed", zap.Error(err))
|
||||
}
|
||||
go s.learnFromDraft(draft, server.ID)
|
||||
|
||||
return docNum, nil
|
||||
}
|
||||
|
||||
@@ -49,9 +49,10 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, userID uuid.UUID, img
|
||||
|
||||
// 2. Создаем черновик
|
||||
draft := &drafts.DraftInvoice{
|
||||
UserID: userID, // <-- Исправлено с ChatID на UserID
|
||||
RMSServerID: serverID, // <-- NEW
|
||||
UserID: userID,
|
||||
RMSServerID: serverID,
|
||||
Status: drafts.StatusProcessing,
|
||||
StoreID: server.DefaultStoreID,
|
||||
}
|
||||
if err := s.draftRepo.Create(draft); err != nil {
|
||||
return nil, fmt.Errorf("failed to create draft: %w", err)
|
||||
@@ -79,7 +80,7 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, userID uuid.UUID, img
|
||||
Sum: decimal.NewFromFloat(rawItem.Sum),
|
||||
}
|
||||
|
||||
match, _ := s.ocrRepo.FindMatch(serverID, rawItem.RawName) // <-- ServerID
|
||||
match, _ := s.ocrRepo.FindMatch(serverID, rawItem.RawName)
|
||||
|
||||
if match != nil {
|
||||
item.IsMatched = true
|
||||
@@ -97,6 +98,8 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, userID uuid.UUID, img
|
||||
s.draftRepo.Update(draft)
|
||||
s.draftRepo.CreateItems(draftItems)
|
||||
|
||||
draft.Items = draftItems
|
||||
|
||||
return draft, nil
|
||||
}
|
||||
|
||||
@@ -122,7 +125,7 @@ func (s *Service) GetCatalogForIndexing(userID uuid.UUID) ([]ProductForIndex, er
|
||||
return nil, fmt.Errorf("no server")
|
||||
}
|
||||
|
||||
products, err := s.catalogRepo.GetActiveGoods(server.ID)
|
||||
products, err := s.catalogRepo.GetActiveGoods(server.ID, server.RootGroupGUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -163,7 +166,7 @@ func (s *Service) SearchProducts(userID uuid.UUID, query string) ([]catalog.Prod
|
||||
if err != nil || server == nil {
|
||||
return nil, fmt.Errorf("no server")
|
||||
}
|
||||
return s.catalogRepo.Search(server.ID, query)
|
||||
return s.catalogRepo.Search(server.ID, query, server.RootGroupGUID)
|
||||
}
|
||||
|
||||
func (s *Service) SaveMapping(userID uuid.UUID, rawName string, productID uuid.UUID, quantity decimal.Decimal, containerID *uuid.UUID) error {
|
||||
|
||||
@@ -77,6 +77,51 @@ type UpdateItemDTO struct {
|
||||
Price float64 `json:"price"`
|
||||
}
|
||||
|
||||
// AddDraftItem - POST /api/drafts/:id/items
|
||||
func (h *DraftsHandler) AddDraftItem(c *gin.Context) {
|
||||
draftID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid draft id"})
|
||||
return
|
||||
}
|
||||
|
||||
item, err := h.service.AddItem(draftID)
|
||||
if err != nil {
|
||||
logger.Log.Error("Failed to add item", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, item)
|
||||
}
|
||||
|
||||
// DeleteDraftItem - DELETE /api/drafts/:id/items/:itemId
|
||||
func (h *DraftsHandler) DeleteDraftItem(c *gin.Context) {
|
||||
draftID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid draft id"})
|
||||
return
|
||||
}
|
||||
itemID, err := uuid.Parse(c.Param("itemId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid item id"})
|
||||
return
|
||||
}
|
||||
|
||||
newTotal, err := h.service.DeleteItem(draftID, itemID)
|
||||
if err != nil {
|
||||
logger.Log.Error("Failed to delete item", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "deleted",
|
||||
"id": itemID.String(),
|
||||
"total_sum": newTotal,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *DraftsHandler) UpdateItem(c *gin.Context) {
|
||||
// userID := c.MustGet("userID").(uuid.UUID) // Пока не используется в UpdateItem, но можно добавить проверку владельца
|
||||
draftID, _ := uuid.Parse(c.Param("id"))
|
||||
|
||||
158
internal/transport/http/handlers/settings.go
Normal file
158
internal/transport/http/handlers/settings.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"rmser/internal/domain/account"
|
||||
"rmser/internal/domain/catalog"
|
||||
"rmser/pkg/logger"
|
||||
)
|
||||
|
||||
type SettingsHandler struct {
|
||||
accountRepo account.Repository
|
||||
catalogRepo catalog.Repository
|
||||
}
|
||||
|
||||
func NewSettingsHandler(accRepo account.Repository, catRepo catalog.Repository) *SettingsHandler {
|
||||
return &SettingsHandler{
|
||||
accountRepo: accRepo,
|
||||
catalogRepo: catRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// GetSettings возвращает настройки активного сервера
|
||||
func (h *SettingsHandler) GetSettings(c *gin.Context) {
|
||||
userID := c.MustGet("userID").(uuid.UUID)
|
||||
|
||||
server, err := h.accountRepo.GetActiveServer(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if server == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "No active server"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, server)
|
||||
}
|
||||
|
||||
// UpdateSettingsDTO
|
||||
type UpdateSettingsDTO struct {
|
||||
Name string `json:"name"`
|
||||
DefaultStoreID string `json:"default_store_id"`
|
||||
RootGroupID string `json:"root_group_id"`
|
||||
AutoProcess bool `json:"auto_process"`
|
||||
}
|
||||
|
||||
// UpdateSettings сохраняет настройки
|
||||
func (h *SettingsHandler) UpdateSettings(c *gin.Context) {
|
||||
userID := c.MustGet("userID").(uuid.UUID)
|
||||
|
||||
var req UpdateSettingsDTO
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
server, err := h.accountRepo.GetActiveServer(userID)
|
||||
if err != nil || server == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "No active server"})
|
||||
return
|
||||
}
|
||||
|
||||
// Обновляем поля
|
||||
if req.Name != "" {
|
||||
server.Name = req.Name
|
||||
}
|
||||
server.AutoProcess = req.AutoProcess
|
||||
|
||||
if req.DefaultStoreID != "" {
|
||||
if uid, err := uuid.Parse(req.DefaultStoreID); err == nil {
|
||||
server.DefaultStoreID = &uid
|
||||
}
|
||||
} else {
|
||||
server.DefaultStoreID = nil
|
||||
}
|
||||
|
||||
// Теперь правильно ловим ID группы
|
||||
if req.RootGroupID != "" {
|
||||
if uid, err := uuid.Parse(req.RootGroupID); err == nil {
|
||||
server.RootGroupGUID = &uid
|
||||
}
|
||||
} else {
|
||||
server.RootGroupGUID = nil
|
||||
}
|
||||
|
||||
if err := h.accountRepo.SaveServer(server); err != nil {
|
||||
logger.Log.Error("Failed to save settings", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, server)
|
||||
}
|
||||
|
||||
// --- Group Tree Logic ---
|
||||
|
||||
type GroupNode struct {
|
||||
Key string `json:"key"` // ID for Ant Design TreeSelect
|
||||
Value string `json:"value"` // ID value
|
||||
Title string `json:"title"` // Name
|
||||
Children []*GroupNode `json:"children"` // Sub-groups
|
||||
}
|
||||
|
||||
// GetGroupsTree возвращает иерархию групп
|
||||
func (h *SettingsHandler) GetGroupsTree(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
|
||||
}
|
||||
|
||||
groups, err := h.catalogRepo.GetGroups(server.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tree := buildTree(groups)
|
||||
c.JSON(http.StatusOK, tree)
|
||||
}
|
||||
|
||||
func buildTree(flat []catalog.Product) []*GroupNode {
|
||||
// 1. Map ID -> Node
|
||||
nodeMap := make(map[uuid.UUID]*GroupNode)
|
||||
for _, g := range flat {
|
||||
nodeMap[g.ID] = &GroupNode{
|
||||
Key: g.ID.String(),
|
||||
Value: g.ID.String(),
|
||||
Title: g.Name,
|
||||
Children: make([]*GroupNode, 0),
|
||||
}
|
||||
}
|
||||
|
||||
var roots []*GroupNode
|
||||
|
||||
// 2. Build Hierarchy
|
||||
for _, g := range flat {
|
||||
node := nodeMap[g.ID]
|
||||
if g.ParentID != nil {
|
||||
if parent, exists := nodeMap[*g.ParentID]; exists {
|
||||
parent.Children = append(parent.Children, node)
|
||||
} else {
|
||||
// Если родителя нет в списке (например, он удален или мы выбрали подмножество),
|
||||
// считаем узлом верхнего уровня
|
||||
roots = append(roots, node)
|
||||
}
|
||||
} else {
|
||||
roots = append(roots, node)
|
||||
}
|
||||
}
|
||||
return roots
|
||||
}
|
||||
@@ -141,6 +141,9 @@ func (bot *Bot) initHandlers() {
|
||||
// Actions Callbacks
|
||||
bot.b.Handle(&tele.Btn{Unique: "act_add_server"}, bot.startAddServerFlow)
|
||||
bot.b.Handle(&tele.Btn{Unique: "act_sync"}, bot.triggerSync)
|
||||
bot.b.Handle(&tele.Btn{Unique: "act_del_server_menu"}, bot.renderDeleteServerMenu)
|
||||
bot.b.Handle(&tele.Btn{Unique: "confirm_name_yes"}, bot.handleConfirmNameYes)
|
||||
bot.b.Handle(&tele.Btn{Unique: "confirm_name_no"}, bot.handleConfirmNameNo)
|
||||
bot.b.Handle(&tele.Btn{Unique: "act_deposit"}, func(c tele.Context) error {
|
||||
return c.Respond(&tele.CallbackResponse{Text: "Функция пополнения в разработке 🛠"})
|
||||
})
|
||||
@@ -151,6 +154,7 @@ func (bot *Bot) initHandlers() {
|
||||
// Input Handlers
|
||||
bot.b.Handle(tele.OnText, bot.handleText)
|
||||
bot.b.Handle(tele.OnPhoto, bot.handlePhoto)
|
||||
|
||||
}
|
||||
|
||||
func (bot *Bot) Start() {
|
||||
@@ -205,9 +209,10 @@ func (bot *Bot) renderServersMenu(c tele.Context) error {
|
||||
}
|
||||
|
||||
btnAdd := menu.Data("➕ Добавить сервер", "act_add_server")
|
||||
btnDel := menu.Data("🗑 Удалить", "act_del_server_menu")
|
||||
btnBack := menu.Data("🔙 Назад", "nav_main")
|
||||
|
||||
rows = append(rows, menu.Row(btnAdd))
|
||||
rows = append(rows, menu.Row(btnAdd, btnDel))
|
||||
rows = append(rows, menu.Row(btnBack))
|
||||
|
||||
menu.Inline(rows...)
|
||||
@@ -264,18 +269,21 @@ func (bot *Bot) renderBalanceMenu(c tele.Context) error {
|
||||
func (bot *Bot) handleCallback(c tele.Context) error {
|
||||
data := c.Callback().Data
|
||||
|
||||
// FIX: Telebot v3 добавляет префикс '\f' к Unique ID кнопки.
|
||||
// Нам нужно удалить его, чтобы корректно парсить строку.
|
||||
if len(data) > 0 && data[0] == '\f' {
|
||||
data = data[1:]
|
||||
}
|
||||
|
||||
// Обработка выбора сервера "set_server_..."
|
||||
if strings.HasPrefix(data, "set_server_") {
|
||||
serverIDStr := strings.TrimPrefix(data, "set_server_")
|
||||
// Удаляем лишние пробелы/символы, которые telebot иногда добавляет (уникальный префикс \f)
|
||||
serverIDStr = strings.TrimSpace(serverIDStr)
|
||||
// Telebot v3: Callback data is prefixed with \f followed by unique id.
|
||||
// But here we use 'data' which is the payload.
|
||||
// NOTE: data variable contains what we passed in .Data() second arg.
|
||||
|
||||
// Split by | just in case middleware adds something, but usually raw string is fine.
|
||||
parts := strings.Split(serverIDStr, "|") // Защита от старых форматов
|
||||
serverIDStr = parts[0]
|
||||
// Защита от старых форматов с разделителем |
|
||||
if idx := strings.Index(serverIDStr, "|"); idx != -1 {
|
||||
serverIDStr = serverIDStr[:idx]
|
||||
}
|
||||
|
||||
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
|
||||
|
||||
@@ -290,30 +298,74 @@ func (bot *Bot) handleCallback(c tele.Context) error {
|
||||
}
|
||||
|
||||
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. Делаем активным
|
||||
// Важно: нужно спарсить UUID
|
||||
// Telebot sometimes sends garbage if Unique is not handled properly.
|
||||
// But we handle OnCallback generally.
|
||||
|
||||
// Fix: В Telebot 3 Data() возвращает payload как есть.
|
||||
// Но лучше быть аккуратным.
|
||||
|
||||
if err := bot.accountRepo.SetActiveServer(userDB.ID, parseUUID(serverIDStr)); err != nil {
|
||||
targetID := parseUUID(serverIDStr)
|
||||
if err := bot.accountRepo.SetActiveServer(userDB.ID, targetID); err != nil {
|
||||
logger.Log.Error("Failed to set active server", zap.Error(err))
|
||||
return c.Respond(&tele.CallbackResponse{Text: "Ошибка смены сервера"})
|
||||
}
|
||||
|
||||
// 3. Сбрасываем кэш фабрики клиентов (чтобы при следующем запросе создался клиент с новыми кредами, если бы они поменялись,
|
||||
// но тут меняется сам сервер, так что Factory.GetClientForUser просто возьмет другой сервер)
|
||||
// Для надежности можно ничего не делать, Factory сама разберется.
|
||||
|
||||
// 3. Успех
|
||||
c.Respond(&tele.CallbackResponse{Text: "✅ Сервер выбран"})
|
||||
return bot.renderServersMenu(c) // Перерисовываем меню
|
||||
}
|
||||
|
||||
// --- ЛОГИКА УДАЛЕНИЯ (новая) ---
|
||||
if strings.HasPrefix(data, "do_del_server_") {
|
||||
serverIDStr := strings.TrimPrefix(data, "do_del_server_")
|
||||
serverIDStr = strings.TrimSpace(serverIDStr)
|
||||
|
||||
// Очистка от мусора
|
||||
if idx := strings.Index(serverIDStr, "|"); idx != -1 {
|
||||
serverIDStr = serverIDStr[:idx]
|
||||
}
|
||||
|
||||
targetID := parseUUID(serverIDStr)
|
||||
if targetID == uuid.Nil {
|
||||
return c.Respond(&tele.CallbackResponse{Text: "Некорректный ID"})
|
||||
}
|
||||
|
||||
// 1. Проверяем, активен ли он сейчас
|
||||
// Нам нужно знать это ДО удаления, чтобы переключить активность
|
||||
// Но проще удалить, а потом проверить, остался ли активный сервер
|
||||
|
||||
// Удаляем
|
||||
if err := bot.accountRepo.DeleteServer(targetID); err != nil {
|
||||
logger.Log.Error("Failed to delete server", zap.Error(err))
|
||||
return c.Respond(&tele.CallbackResponse{Text: "Ошибка удаления"})
|
||||
}
|
||||
|
||||
// Сбрасываем кэш клиента в фабрике
|
||||
bot.rmsFactory.ClearCache(targetID)
|
||||
|
||||
// 2. Проверяем, есть ли активный сервер у пользователя
|
||||
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
|
||||
active, err := bot.accountRepo.GetActiveServer(userDB.ID)
|
||||
|
||||
// Если активного нет (мы удалили активный) или ошибка - назначаем новый
|
||||
if active == nil || err != nil {
|
||||
all, _ := bot.accountRepo.GetAllServers(userDB.ID)
|
||||
if len(all) > 0 {
|
||||
// Делаем активным первый попавшийся
|
||||
_ = bot.accountRepo.SetActiveServer(userDB.ID, all[0].ID)
|
||||
c.Respond(&tele.CallbackResponse{Text: "Сервер удален. Активным назначен " + all[0].Name})
|
||||
} else {
|
||||
c.Respond(&tele.CallbackResponse{Text: "Сервер удален. Список пуст."})
|
||||
}
|
||||
} else {
|
||||
c.Respond(&tele.CallbackResponse{Text: "Сервер удален"})
|
||||
}
|
||||
|
||||
// Возвращаемся в меню удаления (обновляем список)
|
||||
return bot.renderDeleteServerMenu(c)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -366,55 +418,65 @@ func (bot *Bot) handleText(c tele.Context) error {
|
||||
ctx.TempURL = strings.TrimRight(text, "/")
|
||||
ctx.State = StateAddServerLogin
|
||||
})
|
||||
return c.Send("👤 Введите <b>логин</b> пользователя iiko:")
|
||||
return c.Send("👤 Введите логин пользователя iiko:")
|
||||
|
||||
case StateAddServerLogin:
|
||||
bot.fsm.UpdateContext(userID, func(ctx *UserContext) {
|
||||
ctx.TempLogin = text
|
||||
ctx.State = StateAddServerPassword
|
||||
})
|
||||
return c.Send("🔑 Введите <b>пароль</b>:")
|
||||
return c.Send("🔑 Введите пароль:")
|
||||
|
||||
case StateAddServerPassword:
|
||||
password := text
|
||||
ctx := bot.fsm.GetContext(userID)
|
||||
msg, _ := bot.b.Send(c.Sender(), "⏳ Проверяю подключение...")
|
||||
|
||||
// Check connection
|
||||
// 1. Проверяем авторизацию (креды)
|
||||
tempClient := bot.rmsFactory.CreateClientFromRawCredentials(ctx.TempURL, ctx.TempLogin, password)
|
||||
if err := tempClient.Auth(); err != nil {
|
||||
bot.b.Delete(msg)
|
||||
return c.Send(fmt.Sprintf("❌ Ошибка: %v\nПопробуйте ввести пароль снова или начните сначала /add_server", err))
|
||||
return c.Send(fmt.Sprintf("❌ Ошибка авторизации: %v\nПроверьте логин/пароль.", err))
|
||||
}
|
||||
|
||||
// Save
|
||||
encPass, _ := bot.cryptoManager.Encrypt(password)
|
||||
userDB, _ := bot.accountRepo.GetOrCreateUser(userID, c.Sender().Username, "", "")
|
||||
|
||||
newServer := &account.RMSServer{
|
||||
UserID: userDB.ID,
|
||||
Name: "iiko Server " + time.Now().Format("15:04"), // Генерируем имя, чтобы не спрашивать лишнего
|
||||
BaseURL: ctx.TempURL,
|
||||
Login: ctx.TempLogin,
|
||||
EncryptedPassword: encPass,
|
||||
IsActive: true, // Сразу делаем активным
|
||||
// 2. Пробуем узнать имя сервера
|
||||
var detectedName string
|
||||
info, err := rms.GetServerInfo(ctx.TempURL)
|
||||
if err == nil && info.ServerName != "" {
|
||||
detectedName = info.ServerName
|
||||
}
|
||||
|
||||
// Сначала сохраняем, потом делаем активным (через репо сохранения)
|
||||
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.b.Delete(msg)
|
||||
c.Send("✅ <b>Сервер добавлен и выбран активным!</b>", tele.ModeHTML)
|
||||
|
||||
// Auto-sync
|
||||
go bot.syncService.SyncAllData(userDB.ID)
|
||||
// Сохраняем пароль во временный контекст, он нам пригодится при финальном сохранении
|
||||
bot.fsm.UpdateContext(userID, func(uCtx *UserContext) {
|
||||
uCtx.TempPassword = password
|
||||
uCtx.TempServerName = detectedName
|
||||
})
|
||||
|
||||
return bot.renderMainMenu(c)
|
||||
// Если имя нашли - предлагаем выбор
|
||||
if detectedName != "" {
|
||||
bot.fsm.SetState(userID, StateAddServerConfirmName)
|
||||
|
||||
menu := &tele.ReplyMarkup{}
|
||||
btnYes := menu.Data("✅ Да, использовать это имя", "confirm_name_yes")
|
||||
btnNo := menu.Data("✏️ Ввести другое", "confirm_name_no")
|
||||
menu.Inline(menu.Row(btnYes), menu.Row(btnNo))
|
||||
|
||||
return c.Send(fmt.Sprintf("🔎 Обнаружено имя сервера: <b>%s</b>.\nИспользовать его?", detectedName), menu, tele.ModeHTML)
|
||||
}
|
||||
|
||||
// Если имя не нашли - просим ввести вручную
|
||||
bot.fsm.SetState(userID, StateAddServerInputName)
|
||||
return c.Send("🏷 Введите <b>название</b> для этого сервера (для вашего удобства):")
|
||||
|
||||
case StateAddServerInputName:
|
||||
// Пользователь ввел свое название
|
||||
name := text
|
||||
if len(name) < 3 {
|
||||
return c.Send("⚠️ Название слишком короткое.")
|
||||
}
|
||||
return bot.saveServerFinal(c, userID, name)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -488,3 +550,78 @@ func parseUUID(s string) uuid.UUID {
|
||||
id, _ := uuid.Parse(s)
|
||||
return id
|
||||
}
|
||||
|
||||
func (bot *Bot) handleConfirmNameYes(c tele.Context) error {
|
||||
userID := c.Sender().ID
|
||||
ctx := bot.fsm.GetContext(userID)
|
||||
if ctx.State != StateAddServerConfirmName {
|
||||
return c.Respond()
|
||||
}
|
||||
return bot.saveServerFinal(c, userID, ctx.TempServerName)
|
||||
}
|
||||
|
||||
func (bot *Bot) handleConfirmNameNo(c tele.Context) error {
|
||||
userID := c.Sender().ID
|
||||
bot.fsm.SetState(userID, StateAddServerInputName)
|
||||
return c.EditOrSend("🏷 Хорошо, введите желаемое <b>название</b>:")
|
||||
}
|
||||
|
||||
// saveServerFinal - общая логика сохранения в БД
|
||||
func (bot *Bot) saveServerFinal(c tele.Context, userID int64, serverName string) error {
|
||||
ctx := bot.fsm.GetContext(userID)
|
||||
|
||||
encPass, _ := bot.cryptoManager.Encrypt(ctx.TempPassword)
|
||||
userDB, _ := bot.accountRepo.GetOrCreateUser(userID, c.Sender().Username, "", "")
|
||||
|
||||
newServer := &account.RMSServer{
|
||||
UserID: userDB.ID,
|
||||
Name: serverName,
|
||||
BaseURL: ctx.TempURL,
|
||||
Login: ctx.TempLogin,
|
||||
EncryptedPassword: encPass,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if err := bot.accountRepo.SaveServer(newServer); err != nil {
|
||||
return c.Send("Ошибка сохранения в БД: " + err.Error())
|
||||
}
|
||||
|
||||
bot.accountRepo.SetActiveServer(userDB.ID, newServer.ID)
|
||||
bot.fsm.Reset(userID)
|
||||
|
||||
c.Send(fmt.Sprintf("✅ Сервер <b>%s</b> успешно добавлен!", serverName), tele.ModeHTML)
|
||||
|
||||
// Auto-sync
|
||||
go bot.syncService.SyncAllData(userDB.ID)
|
||||
|
||||
return bot.renderMainMenu(c)
|
||||
}
|
||||
|
||||
func (bot *Bot) renderDeleteServerMenu(c tele.Context) error {
|
||||
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
|
||||
servers, err := bot.accountRepo.GetAllServers(userDB.ID)
|
||||
if err != nil {
|
||||
return c.Send("Ошибка БД: " + err.Error())
|
||||
}
|
||||
|
||||
if len(servers) == 0 {
|
||||
return c.Respond(&tele.CallbackResponse{Text: "Список серверов пуст"})
|
||||
}
|
||||
|
||||
menu := &tele.ReplyMarkup{}
|
||||
var rows []tele.Row
|
||||
|
||||
for _, s := range servers {
|
||||
// Кнопка удаления для каждого сервера
|
||||
// Префикс do_del_server_
|
||||
btn := menu.Data(fmt.Sprintf("❌ %s", s.Name), "do_del_server_"+s.ID.String())
|
||||
rows = append(rows, menu.Row(btn))
|
||||
}
|
||||
|
||||
btnBack := menu.Data("🔙 Назад к списку", "nav_servers")
|
||||
rows = append(rows, menu.Row(btnBack))
|
||||
|
||||
menu.Inline(rows...)
|
||||
|
||||
return c.EditOrSend("🗑 <b>Удаление сервера</b>\n\nНажмите на сервер, который хотите удалить.\nЭто действие нельзя отменить.", menu, tele.ModeHTML)
|
||||
}
|
||||
|
||||
@@ -10,14 +10,17 @@ const (
|
||||
StateAddServerURL
|
||||
StateAddServerLogin
|
||||
StateAddServerPassword
|
||||
StateAddServerConfirmName
|
||||
StateAddServerInputName
|
||||
)
|
||||
|
||||
// UserContext хранит временные данные в процессе диалога
|
||||
type UserContext struct {
|
||||
State State
|
||||
TempURL string
|
||||
TempLogin string
|
||||
TempPassword string
|
||||
State State
|
||||
TempURL string
|
||||
TempLogin string
|
||||
TempPassword string
|
||||
TempServerName string
|
||||
}
|
||||
|
||||
// StateManager управляет состояниями
|
||||
|
||||
Reference in New Issue
Block a user