mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
Перевел на multi-tenant
Добавил поставщиков Накладные успешно создаются из фронта
This commit is contained in:
@@ -21,8 +21,9 @@ func NewDraftsHandler(service *drafts.Service) *DraftsHandler {
|
||||
return &DraftsHandler{service: service}
|
||||
}
|
||||
|
||||
// GetDraft возвращает полные данные черновика
|
||||
// GetDraft
|
||||
func (h *DraftsHandler) GetDraft(c *gin.Context) {
|
||||
userID := c.MustGet("userID").(uuid.UUID)
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
@@ -30,7 +31,7 @@ func (h *DraftsHandler) GetDraft(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
draft, err := h.service.GetDraft(id)
|
||||
draft, err := h.service.GetDraft(id, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "draft not found"})
|
||||
return
|
||||
@@ -38,17 +39,37 @@ func (h *DraftsHandler) GetDraft(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, draft)
|
||||
}
|
||||
|
||||
// GetStores возвращает список складов
|
||||
func (h *DraftsHandler) GetStores(c *gin.Context) {
|
||||
stores, err := h.service.GetActiveStores()
|
||||
// GetDictionaries (бывший GetStores)
|
||||
func (h *DraftsHandler) GetDictionaries(c *gin.Context) {
|
||||
userID := c.MustGet("userID").(uuid.UUID)
|
||||
|
||||
data, err := h.service.GetDictionaries(userID)
|
||||
if err != nil {
|
||||
logger.Log.Error("GetDictionaries error", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, stores)
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
// UpdateItemDTO - тело запроса на изменение строки
|
||||
// GetStores - устаревший метод для обратной совместимости
|
||||
// Возвращает массив складов
|
||||
func (h *DraftsHandler) GetStores(c *gin.Context) {
|
||||
userID := c.MustGet("userID").(uuid.UUID)
|
||||
|
||||
// Используем логику из GetDictionaries, но возвращаем только stores
|
||||
dict, err := h.service.GetDictionaries(userID)
|
||||
if err != nil {
|
||||
logger.Log.Error("GetStores error", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// dict["stores"] уже содержит []catalog.Store
|
||||
c.JSON(http.StatusOK, dict["stores"])
|
||||
}
|
||||
|
||||
// UpdateItemDTO
|
||||
type UpdateItemDTO struct {
|
||||
ProductID *string `json:"product_id"`
|
||||
ContainerID *string `json:"container_id"`
|
||||
@@ -57,6 +78,7 @@ type UpdateItemDTO struct {
|
||||
}
|
||||
|
||||
func (h *DraftsHandler) UpdateItem(c *gin.Context) {
|
||||
// userID := c.MustGet("userID").(uuid.UUID) // Пока не используется в UpdateItem, но можно добавить проверку владельца
|
||||
draftID, _ := uuid.Parse(c.Param("id"))
|
||||
itemID, _ := uuid.Parse(c.Param("itemId"))
|
||||
|
||||
@@ -99,8 +121,8 @@ type CommitRequestDTO struct {
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
|
||||
// CommitDraft сохраняет шапку и отправляет в RMS
|
||||
func (h *DraftsHandler) CommitDraft(c *gin.Context) {
|
||||
userID := c.MustGet("userID").(uuid.UUID)
|
||||
draftID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid draft id"})
|
||||
@@ -113,10 +135,9 @@ func (h *DraftsHandler) CommitDraft(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Парсинг данных шапки
|
||||
date, err := time.Parse("2006-01-02", req.DateIncoming)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid date format (YYYY-MM-DD)"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid date format"})
|
||||
return
|
||||
}
|
||||
storeID, err := uuid.Parse(req.StoreID)
|
||||
@@ -130,35 +151,30 @@ func (h *DraftsHandler) CommitDraft(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 1. Обновляем шапку
|
||||
if err := h.service.UpdateDraftHeader(draftID, &storeID, &supplierID, date, req.Comment); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update header: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Отправляем
|
||||
docNum, err := h.service.CommitDraft(draftID)
|
||||
docNum, err := h.service.CommitDraft(draftID, userID)
|
||||
if err != nil {
|
||||
logger.Log.Error("Commit failed", zap.Error(err))
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "RMS error: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "completed",
|
||||
"document_number": docNum,
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"status": "completed", "document_number": docNum})
|
||||
}
|
||||
|
||||
// AddContainerRequestDTO - запрос на создание фасовки
|
||||
type AddContainerRequestDTO struct {
|
||||
ProductID string `json:"product_id" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Count float64 `json:"count" binding:"required,gt=0"`
|
||||
}
|
||||
|
||||
// AddContainer создает новую фасовку для товара
|
||||
func (h *DraftsHandler) AddContainer(c *gin.Context) {
|
||||
userID := c.MustGet("userID").(uuid.UUID)
|
||||
|
||||
var req AddContainerRequestDTO
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -171,29 +187,22 @@ func (h *DraftsHandler) AddContainer(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Конвертация float64 -> decimal
|
||||
countDec := decimal.NewFromFloat(req.Count)
|
||||
|
||||
// Вызов сервиса
|
||||
newID, err := h.service.CreateProductContainer(pID, req.Name, countDec)
|
||||
newID, err := h.service.CreateProductContainer(userID, pID, req.Name, countDec)
|
||||
if err != nil {
|
||||
logger.Log.Error("Failed to create container", zap.Error(err))
|
||||
// Можно возвращать 502, если ошибка от RMS
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "created",
|
||||
"container_id": newID.String(),
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"status": "created", "container_id": newID.String()})
|
||||
}
|
||||
|
||||
// DraftListItemDTO - структура элемента списка
|
||||
type DraftListItemDTO struct {
|
||||
ID string `json:"id"`
|
||||
DocumentNumber string `json:"document_number"`
|
||||
DateIncoming string `json:"date_incoming"` // YYYY-MM-DD
|
||||
DateIncoming string `json:"date_incoming"`
|
||||
Status string `json:"status"`
|
||||
ItemsCount int `json:"items_count"`
|
||||
TotalSum float64 `json:"total_sum"`
|
||||
@@ -201,38 +210,30 @@ type DraftListItemDTO struct {
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// GetDrafts возвращает список активных черновиков
|
||||
func (h *DraftsHandler) GetDrafts(c *gin.Context) {
|
||||
list, err := h.service.GetActiveDrafts()
|
||||
userID := c.MustGet("userID").(uuid.UUID)
|
||||
list, err := h.service.GetActiveDrafts(userID)
|
||||
if err != nil {
|
||||
logger.Log.Error("Failed to fetch drafts", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
response := make([]DraftListItemDTO, 0, len(list))
|
||||
|
||||
for _, d := range list {
|
||||
// Расчет суммы
|
||||
var totalSum decimal.Decimal
|
||||
for _, item := range d.Items {
|
||||
// Если item.Sum посчитана - берем её, иначе (qty * price)
|
||||
if !item.Sum.IsZero() {
|
||||
totalSum = totalSum.Add(item.Sum)
|
||||
} else {
|
||||
totalSum = totalSum.Add(item.Quantity.Mul(item.Price))
|
||||
}
|
||||
}
|
||||
|
||||
sumFloat, _ := totalSum.Float64()
|
||||
|
||||
// Форматирование даты
|
||||
dateStr := ""
|
||||
if d.DateIncoming != nil {
|
||||
dateStr = d.DateIncoming.Format("2006-01-02")
|
||||
}
|
||||
|
||||
// Имя склада
|
||||
storeName := ""
|
||||
if d.Store != nil {
|
||||
storeName = d.Store.Name
|
||||
@@ -249,12 +250,11 @@ func (h *DraftsHandler) GetDrafts(c *gin.Context) {
|
||||
CreatedAt: d.CreatedAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// DeleteDraft обрабатывает запрос на удаление/отмену
|
||||
func (h *DraftsHandler) DeleteDraft(c *gin.Context) {
|
||||
// userID := c.MustGet("userID").(uuid.UUID) // Можно добавить проверку владельца
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
@@ -264,14 +264,9 @@ func (h *DraftsHandler) DeleteDraft(c *gin.Context) {
|
||||
|
||||
newStatus, err := h.service.DeleteDraft(id)
|
||||
if err != nil {
|
||||
logger.Log.Error("Failed to delete draft", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Возвращаем новый статус, чтобы фронтенд знал, удалился он совсем или стал CANCELED
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": newStatus,
|
||||
"id": id.String(),
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": newStatus, "id": id.String()})
|
||||
}
|
||||
|
||||
@@ -22,9 +22,19 @@ func NewOCRHandler(service *ocrService.Service) *OCRHandler {
|
||||
|
||||
// GetCatalog возвращает список товаров для OCR сервиса
|
||||
func (h *OCRHandler) GetCatalog(c *gin.Context) {
|
||||
items, err := h.service.GetCatalogForIndexing()
|
||||
// Если этот эндпоинт дергает Python-скрипт без токена пользователя - это проблема безопасности.
|
||||
// Либо Python скрипт должен передавать токен админа/системы и ID сервера в query.
|
||||
// ПОКА: Предполагаем, что запрос идет от фронта или с заголовком X-Telegram-User-ID.
|
||||
|
||||
// Если заголовка нет (вызов от скрипта), пробуем взять server_id из query (небезопасно, но для MVP)
|
||||
// Или лучше так: этот метод вызывается Фронтендом для поиска? Нет, название GetCatalogForIndexing намекает на OCR.
|
||||
// Оставим пока требование UserID.
|
||||
|
||||
userID := c.MustGet("userID").(uuid.UUID)
|
||||
|
||||
items, err := h.service.GetCatalogForIndexing(userID)
|
||||
if err != nil {
|
||||
logger.Log.Error("Ошибка получения каталога для OCR", zap.Error(err))
|
||||
logger.Log.Error("Ошибка получения каталога", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -38,8 +48,9 @@ type MatchRequest struct {
|
||||
ContainerID *string `json:"container_id"`
|
||||
}
|
||||
|
||||
// SaveMatch сохраняет привязку (обучение)
|
||||
func (h *OCRHandler) SaveMatch(c *gin.Context) {
|
||||
userID := c.MustGet("userID").(uuid.UUID)
|
||||
|
||||
var req MatchRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -64,7 +75,7 @@ func (h *OCRHandler) SaveMatch(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.service.SaveMapping(req.RawName, pID, qty, contID); err != nil {
|
||||
if err := h.service.SaveMapping(userID, req.RawName, pID, qty, contID); err != nil {
|
||||
logger.Log.Error("Ошибка сохранения матчинга", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -73,18 +84,16 @@ func (h *OCRHandler) SaveMatch(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "saved"})
|
||||
}
|
||||
|
||||
// DeleteMatch удаляет связь
|
||||
func (h *OCRHandler) DeleteMatch(c *gin.Context) {
|
||||
// Получаем raw_name из query параметров, так как в URL path могут быть спецсимволы
|
||||
// Пример: DELETE /api/ocr/match?raw_name=Хлеб%20Бородинский
|
||||
userID := c.MustGet("userID").(uuid.UUID)
|
||||
rawName := c.Query("raw_name")
|
||||
|
||||
if rawName == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "raw_name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.DeleteMatch(rawName); err != nil {
|
||||
logger.Log.Error("Ошибка удаления матча", zap.Error(err))
|
||||
if err := h.service.DeleteMatch(userID, rawName); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -92,43 +101,32 @@ func (h *OCRHandler) DeleteMatch(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||
}
|
||||
|
||||
// SearchProducts ищет товары (для автокомплита)
|
||||
func (h *OCRHandler) SearchProducts(c *gin.Context) {
|
||||
query := c.Query("q") // ?q=молоко
|
||||
if query == "" {
|
||||
c.JSON(http.StatusOK, []interface{}{})
|
||||
return
|
||||
}
|
||||
userID := c.MustGet("userID").(uuid.UUID)
|
||||
query := c.Query("q")
|
||||
|
||||
products, err := h.service.SearchProducts(query)
|
||||
products, err := h.service.SearchProducts(userID, query)
|
||||
if err != nil {
|
||||
logger.Log.Error("Search error", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Отдаем на фронт упрощенную структуру или полную, в зависимости от нужд.
|
||||
// Product entity уже содержит JSON теги, так что можно отдать напрямую.
|
||||
c.JSON(http.StatusOK, products)
|
||||
}
|
||||
|
||||
// GetMatches возвращает список всех обученных связей
|
||||
func (h *OCRHandler) GetMatches(c *gin.Context) {
|
||||
matches, err := h.service.GetKnownMatches()
|
||||
userID := c.MustGet("userID").(uuid.UUID)
|
||||
matches, err := h.service.GetKnownMatches(userID)
|
||||
if err != nil {
|
||||
logger.Log.Error("Ошибка получения списка матчей", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, matches)
|
||||
}
|
||||
|
||||
// GetUnmatched возвращает список нераспознанных позиций для подсказок
|
||||
func (h *OCRHandler) GetUnmatched(c *gin.Context) {
|
||||
items, err := h.service.GetUnmatchedItems()
|
||||
userID := c.MustGet("userID").(uuid.UUID)
|
||||
items, err := h.service.GetUnmatchedItems(userID)
|
||||
if err != nil {
|
||||
logger.Log.Error("Ошибка получения списка unmatched", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
49
internal/transport/http/middleware/auth.go
Normal file
49
internal/transport/http/middleware/auth.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"rmser/internal/domain/account"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AuthMiddleware извлекает Telegram User ID и находит User UUID
|
||||
func AuthMiddleware(accountRepo account.Repository) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 1. Ищем в заголовке (стандартный путь)
|
||||
tgIDStr := c.GetHeader("X-Telegram-User-ID")
|
||||
|
||||
// 2. Если нет в заголовке, ищем в Query (для отладки в браузере)
|
||||
// Пример: /api/drafts?_tg_id=12345678
|
||||
if tgIDStr == "" {
|
||||
tgIDStr = c.Query("_tg_id")
|
||||
}
|
||||
|
||||
if tgIDStr == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Missing X-Telegram-User-ID header or _tg_id param"})
|
||||
return
|
||||
}
|
||||
|
||||
tgID, err := strconv.ParseInt(tgIDStr, 10, 64)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Telegram ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Ищем пользователя в БД
|
||||
user, err := accountRepo.GetUserByTelegramID(tgID)
|
||||
if err != nil {
|
||||
// Если пользователя нет - значит он не нажал /start в боте
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "User not registered via Bot. Please start the bot first."})
|
||||
return
|
||||
}
|
||||
|
||||
// Кладем UUID пользователя в контекст
|
||||
c.Set("userID", user.ID)
|
||||
c.Set("telegramID", tgID)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -8,23 +8,48 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
tele "gopkg.in/telebot.v3"
|
||||
"gopkg.in/telebot.v3/middleware"
|
||||
|
||||
"rmser/config"
|
||||
"rmser/internal/domain/account"
|
||||
"rmser/internal/infrastructure/rms"
|
||||
"rmser/internal/services/ocr"
|
||||
"rmser/internal/services/sync"
|
||||
"rmser/pkg/crypto"
|
||||
"rmser/pkg/logger"
|
||||
)
|
||||
|
||||
type Bot struct {
|
||||
b *tele.Bot
|
||||
ocrService *ocr.Service
|
||||
adminIDs map[int64]struct{}
|
||||
webAppURL string
|
||||
b *tele.Bot
|
||||
ocrService *ocr.Service
|
||||
syncService *sync.Service
|
||||
accountRepo account.Repository
|
||||
rmsFactory *rms.Factory
|
||||
cryptoManager *crypto.CryptoManager
|
||||
|
||||
fsm *StateManager
|
||||
adminIDs map[int64]struct{}
|
||||
webAppURL string
|
||||
|
||||
// UI Elements (Menus)
|
||||
menuMain *tele.ReplyMarkup
|
||||
menuServers *tele.ReplyMarkup
|
||||
menuDicts *tele.ReplyMarkup
|
||||
menuBalance *tele.ReplyMarkup
|
||||
}
|
||||
|
||||
func NewBot(cfg config.TelegramConfig, ocrService *ocr.Service) (*Bot, error) {
|
||||
func NewBot(
|
||||
cfg config.TelegramConfig,
|
||||
ocrService *ocr.Service,
|
||||
syncService *sync.Service,
|
||||
accountRepo account.Repository,
|
||||
rmsFactory *rms.Factory,
|
||||
cryptoManager *crypto.CryptoManager,
|
||||
) (*Bot, error) {
|
||||
|
||||
pref := tele.Settings{
|
||||
Token: cfg.Token,
|
||||
Poller: &tele.LongPoller{Timeout: 10 * time.Second},
|
||||
@@ -44,64 +69,374 @@ func NewBot(cfg config.TelegramConfig, ocrService *ocr.Service) (*Bot, error) {
|
||||
}
|
||||
|
||||
bot := &Bot{
|
||||
b: b,
|
||||
ocrService: ocrService,
|
||||
adminIDs: admins,
|
||||
webAppURL: cfg.WebAppURL,
|
||||
b: b,
|
||||
ocrService: ocrService,
|
||||
syncService: syncService,
|
||||
accountRepo: accountRepo,
|
||||
rmsFactory: rmsFactory,
|
||||
cryptoManager: cryptoManager,
|
||||
fsm: NewStateManager(),
|
||||
adminIDs: admins,
|
||||
webAppURL: cfg.WebAppURL,
|
||||
}
|
||||
|
||||
// Если в конфиге пусто, ставим заглушку, чтобы не падало, но предупреждаем
|
||||
if bot.webAppURL == "" {
|
||||
logger.Log.Warn("Telegram WebAppURL не задан в конфиге! Кнопки работать не будут.")
|
||||
bot.webAppURL = "http://example.com"
|
||||
}
|
||||
|
||||
bot.initMenus()
|
||||
bot.initHandlers()
|
||||
return bot, nil
|
||||
}
|
||||
|
||||
// initMenus инициализирует статические кнопки
|
||||
func (bot *Bot) initMenus() {
|
||||
// --- MAIN MENU ---
|
||||
bot.menuMain = &tele.ReplyMarkup{}
|
||||
btnServers := bot.menuMain.Data("🖥 Серверы", "nav_servers")
|
||||
btnDicts := bot.menuMain.Data("🔄 Справочники", "nav_dicts")
|
||||
btnBalance := bot.menuMain.Data("💰 Баланс", "nav_balance")
|
||||
btnApp := bot.menuMain.WebApp("📱 Открыть приложение", &tele.WebApp{URL: bot.webAppURL})
|
||||
|
||||
bot.menuMain.Inline(
|
||||
bot.menuMain.Row(btnServers, btnDicts),
|
||||
bot.menuMain.Row(btnBalance),
|
||||
bot.menuMain.Row(btnApp),
|
||||
)
|
||||
|
||||
// --- SERVERS MENU (Dynamic part logic is in handler) ---
|
||||
bot.menuServers = &tele.ReplyMarkup{}
|
||||
|
||||
// --- DICTIONARIES MENU ---
|
||||
bot.menuDicts = &tele.ReplyMarkup{}
|
||||
btnSync := bot.menuDicts.Data("⚡️ Обновить данные", "act_sync")
|
||||
btnBack := bot.menuDicts.Data("🔙 Назад", "nav_main")
|
||||
bot.menuDicts.Inline(
|
||||
bot.menuDicts.Row(btnSync),
|
||||
bot.menuDicts.Row(btnBack),
|
||||
)
|
||||
|
||||
// --- BALANCE MENU ---
|
||||
bot.menuBalance = &tele.ReplyMarkup{}
|
||||
btnDeposit := bot.menuBalance.Data("💳 Пополнить (Demo)", "act_deposit")
|
||||
bot.menuBalance.Inline(
|
||||
bot.menuBalance.Row(btnDeposit),
|
||||
bot.menuBalance.Row(btnBack),
|
||||
)
|
||||
}
|
||||
|
||||
func (bot *Bot) initHandlers() {
|
||||
bot.b.Use(middleware.Logger())
|
||||
bot.b.Use(bot.registrationMiddleware)
|
||||
|
||||
// Commands
|
||||
bot.b.Handle("/start", bot.renderMainMenu)
|
||||
|
||||
// Navigation Callbacks
|
||||
bot.b.Handle(&tele.Btn{Unique: "nav_main"}, bot.renderMainMenu)
|
||||
bot.b.Handle(&tele.Btn{Unique: "nav_servers"}, bot.renderServersMenu)
|
||||
bot.b.Handle(&tele.Btn{Unique: "nav_dicts"}, bot.renderDictsMenu)
|
||||
bot.b.Handle(&tele.Btn{Unique: "nav_balance"}, bot.renderBalanceMenu)
|
||||
|
||||
// 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_deposit"}, func(c tele.Context) error {
|
||||
return c.Respond(&tele.CallbackResponse{Text: "Функция пополнения в разработке 🛠"})
|
||||
})
|
||||
|
||||
// Dynamic Handler for server selection ("set_server_UUID")
|
||||
bot.b.Handle(tele.OnCallback, bot.handleCallback)
|
||||
|
||||
// Input Handlers
|
||||
bot.b.Handle(tele.OnText, bot.handleText)
|
||||
bot.b.Handle(tele.OnPhoto, bot.handlePhoto)
|
||||
}
|
||||
|
||||
func (bot *Bot) Start() {
|
||||
logger.Log.Info("Запуск Telegram бота...")
|
||||
bot.b.Start()
|
||||
}
|
||||
|
||||
func (bot *Bot) Stop() {
|
||||
bot.b.Stop()
|
||||
}
|
||||
func (bot *Bot) Stop() { bot.b.Stop() }
|
||||
|
||||
// Middleware для проверки прав (только админы)
|
||||
func (bot *Bot) authMiddleware(next tele.HandlerFunc) tele.HandlerFunc {
|
||||
func (bot *Bot) registrationMiddleware(next tele.HandlerFunc) tele.HandlerFunc {
|
||||
return func(c tele.Context) error {
|
||||
if len(bot.adminIDs) > 0 {
|
||||
if _, ok := bot.adminIDs[c.Sender().ID]; !ok {
|
||||
return c.Send("⛔ У вас нет доступа к этому боту.")
|
||||
}
|
||||
user := c.Sender()
|
||||
_, err := bot.accountRepo.GetOrCreateUser(user.ID, user.Username, user.FirstName, user.LastName)
|
||||
if err != nil {
|
||||
logger.Log.Error("Failed to register user", zap.Error(err))
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
func (bot *Bot) initHandlers() {
|
||||
bot.b.Use(middleware.Logger())
|
||||
bot.b.Use(bot.authMiddleware)
|
||||
// --- RENDERERS (View Layer) ---
|
||||
|
||||
bot.b.Handle("/start", func(c tele.Context) error {
|
||||
return c.Send("👋 Привет! Я RMSER Bot.\nОтправь мне фото накладной или чека, и я попробую его распознать.")
|
||||
})
|
||||
func (bot *Bot) renderMainMenu(c tele.Context) error {
|
||||
// Сбрасываем стейты FSM, если пользователь вернулся в меню
|
||||
bot.fsm.Reset(c.Sender().ID)
|
||||
|
||||
bot.b.Handle(tele.OnPhoto, bot.handlePhoto)
|
||||
txt := "👋 <b>Панель управления RMSER</b>\n\n" +
|
||||
"Здесь вы можете управлять подключенными серверами iiko и следить за актуальностью справочников."
|
||||
|
||||
return c.EditOrSend(txt, bot.menuMain, tele.ModeHTML)
|
||||
}
|
||||
|
||||
func (bot *Bot) renderServersMenu(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())
|
||||
}
|
||||
|
||||
menu := &tele.ReplyMarkup{}
|
||||
var rows []tele.Row
|
||||
|
||||
// Генерируем кнопки для каждого сервера
|
||||
for _, s := range servers {
|
||||
icon := "🔴"
|
||||
if s.IsActive {
|
||||
icon = "🟢"
|
||||
}
|
||||
// Payload: "set_server_<UUID>"
|
||||
btn := menu.Data(fmt.Sprintf("%s %s", icon, s.Name), "set_server_"+s.ID.String())
|
||||
rows = append(rows, menu.Row(btn))
|
||||
}
|
||||
|
||||
btnAdd := menu.Data("➕ Добавить сервер", "act_add_server")
|
||||
btnBack := menu.Data("🔙 Назад", "nav_main")
|
||||
|
||||
rows = append(rows, menu.Row(btnAdd))
|
||||
rows = append(rows, menu.Row(btnBack))
|
||||
|
||||
menu.Inline(rows...)
|
||||
|
||||
txt := fmt.Sprintf("<b>🖥 Ваши серверы (%d):</b>\n\nНажмите на сервер, чтобы сделать его активным.", len(servers))
|
||||
return c.EditOrSend(txt, menu, tele.ModeHTML)
|
||||
}
|
||||
|
||||
func (bot *Bot) renderDictsMenu(c tele.Context) error {
|
||||
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
|
||||
|
||||
stats, err := bot.syncService.GetSyncStats(userDB.ID)
|
||||
|
||||
var txt string
|
||||
if err != nil {
|
||||
txt = fmt.Sprintf("⚠️ <b>Статус:</b> Ошибка (%v)", err)
|
||||
} else {
|
||||
lastUpdate := "—"
|
||||
if stats.LastInvoice != nil {
|
||||
lastUpdate = stats.LastInvoice.Format("02.01.2006")
|
||||
}
|
||||
|
||||
txt = fmt.Sprintf("<b>🔄 Состояние справочников</b>\n\n"+
|
||||
"🏢 <b>Сервер:</b> %s\n"+
|
||||
"📦 <b>Товары:</b> %d\n"+
|
||||
"🚚 <b>Поставщики:</b> %d\n"+
|
||||
"🏭 <b>Склады:</b> %d\n\n"+
|
||||
"📄 <b>Накладные (30дн):</b> %d\n"+
|
||||
"📅 <b>Посл. документ:</b> %s\n\n"+
|
||||
"Нажмите «Обновить», чтобы синхронизировать данные.",
|
||||
stats.ServerName,
|
||||
stats.ProductsCount,
|
||||
stats.SuppliersCount,
|
||||
stats.StoresCount,
|
||||
stats.InvoicesLast30,
|
||||
lastUpdate)
|
||||
}
|
||||
|
||||
return c.EditOrSend(txt, bot.menuDicts, tele.ModeHTML)
|
||||
}
|
||||
|
||||
func (bot *Bot) renderBalanceMenu(c tele.Context) error {
|
||||
// Заглушка баланса
|
||||
txt := "<b>💰 Ваш баланс</b>\n\n" +
|
||||
"💵 Текущий счет: <b>0.00 ₽</b>\n" +
|
||||
"💎 Тариф: <b>Free</b>\n\n" +
|
||||
"Пока сервис работает в бета-режиме, использование бесплатно."
|
||||
|
||||
return c.EditOrSend(txt, bot.menuBalance, tele.ModeHTML)
|
||||
}
|
||||
|
||||
// --- LOGIC HANDLERS ---
|
||||
|
||||
func (bot *Bot) handleCallback(c tele.Context) error {
|
||||
data := c.Callback().Data
|
||||
|
||||
// Обработка выбора сервера "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]
|
||||
|
||||
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 {
|
||||
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 {
|
||||
logger.Log.Error("Failed to set active server", zap.Error(err))
|
||||
return c.Respond(&tele.CallbackResponse{Text: "Ошибка смены сервера"})
|
||||
}
|
||||
|
||||
// 3. Сбрасываем кэш фабрики клиентов (чтобы при следующем запросе создался клиент с новыми кредами, если бы они поменялись,
|
||||
// но тут меняется сам сервер, так что Factory.GetClientForUser просто возьмет другой сервер)
|
||||
// Для надежности можно ничего не делать, Factory сама разберется.
|
||||
|
||||
c.Respond(&tele.CallbackResponse{Text: "✅ Сервер выбран"})
|
||||
return bot.renderServersMenu(c) // Перерисовываем меню
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (bot *Bot) triggerSync(c tele.Context) error {
|
||||
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
|
||||
|
||||
c.Respond(&tele.CallbackResponse{Text: "Запускаю синхронизацию..."})
|
||||
|
||||
// Запускаем в фоне, но уведомляем юзера
|
||||
go func() {
|
||||
if err := bot.syncService.SyncAllData(userDB.ID); err != nil {
|
||||
logger.Log.Error("Manual sync failed", zap.Error(err))
|
||||
bot.b.Send(c.Sender(), "❌ Ошибка синхронизации. Проверьте настройки сервера.")
|
||||
} else {
|
||||
bot.b.Send(c.Sender(), "✅ Синхронизация успешно завершена!")
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- FSM: ADD SERVER FLOW ---
|
||||
|
||||
func (bot *Bot) startAddServerFlow(c tele.Context) error {
|
||||
bot.fsm.SetState(c.Sender().ID, StateAddServerURL)
|
||||
return c.EditOrSend("🔗 Введите <b>URL</b> вашего сервера iikoRMS.\nПример: <code>https://iiko.myrest.ru:443</code>\n\n(Напишите 'отмена' для выхода)", tele.ModeHTML)
|
||||
}
|
||||
|
||||
func (bot *Bot) handleText(c tele.Context) error {
|
||||
userID := c.Sender().ID
|
||||
state := bot.fsm.GetState(userID)
|
||||
text := strings.TrimSpace(c.Text())
|
||||
|
||||
// Глобальная отмена
|
||||
if strings.ToLower(text) == "отмена" || strings.ToLower(text) == "/cancel" {
|
||||
bot.fsm.Reset(userID)
|
||||
return bot.renderMainMenu(c)
|
||||
}
|
||||
|
||||
if state == StateNone {
|
||||
return c.Send("Используйте меню для навигации 👇")
|
||||
}
|
||||
|
||||
switch state {
|
||||
case StateAddServerURL:
|
||||
if !strings.HasPrefix(text, "http") {
|
||||
return c.Send("❌ URL должен начинаться с http:// или https://\nПопробуйте снова.")
|
||||
}
|
||||
bot.fsm.UpdateContext(userID, func(ctx *UserContext) {
|
||||
ctx.TempURL = strings.TrimRight(text, "/")
|
||||
ctx.State = StateAddServerLogin
|
||||
})
|
||||
return c.Send("👤 Введите <b>логин</b> пользователя iiko:")
|
||||
|
||||
case StateAddServerLogin:
|
||||
bot.fsm.UpdateContext(userID, func(ctx *UserContext) {
|
||||
ctx.TempLogin = text
|
||||
ctx.State = StateAddServerPassword
|
||||
})
|
||||
return c.Send("🔑 Введите <b>пароль</b>:")
|
||||
|
||||
case StateAddServerPassword:
|
||||
password := text
|
||||
ctx := bot.fsm.GetContext(userID)
|
||||
msg, _ := bot.b.Send(c.Sender(), "⏳ Проверяю подключение...")
|
||||
|
||||
// Check connection
|
||||
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))
|
||||
}
|
||||
|
||||
// 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, // Сразу делаем активным
|
||||
}
|
||||
|
||||
// Сначала сохраняем, потом делаем активным (через репо сохранения)
|
||||
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)
|
||||
|
||||
return bot.renderMainMenu(c)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (bot *Bot) handlePhoto(c tele.Context) error {
|
||||
// 1. Скачиваем фото
|
||||
userDB, err := bot.accountRepo.GetOrCreateUser(c.Sender().ID, c.Sender().Username, "", "")
|
||||
if err != nil {
|
||||
return c.Send("Ошибка базы данных пользователей")
|
||||
}
|
||||
|
||||
_, err = bot.rmsFactory.GetClientForUser(userDB.ID)
|
||||
if err != nil {
|
||||
return c.Send("⛔ У вас не настроен сервер iiko.\nИспользуйте /add_server для настройки.")
|
||||
}
|
||||
|
||||
photo := c.Message().Photo
|
||||
// Берем файл самого высокого качества (последний в массиве, но telebot дает удобный доступ)
|
||||
file, err := bot.b.FileByID(photo.FileID)
|
||||
if err != nil {
|
||||
return c.Send("Ошибка доступа к файлу.")
|
||||
}
|
||||
|
||||
// Читаем тело файла
|
||||
fileURL := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", bot.b.Token, file.FilePath)
|
||||
resp, err := http.Get(fileURL)
|
||||
if err != nil {
|
||||
@@ -116,17 +451,15 @@ func (bot *Bot) handlePhoto(c tele.Context) error {
|
||||
|
||||
c.Send("⏳ Обрабатываю чек: создаю черновик и распознаю...")
|
||||
|
||||
// 2. Отправляем в сервис (добавили ID чата)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) // Чуть увеличим таймаут
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
|
||||
draft, err := bot.ocrService.ProcessReceiptImage(ctx, c.Chat().ID, imgData)
|
||||
draft, err := bot.ocrService.ProcessReceiptImage(ctx, userDB.ID, imgData)
|
||||
if err != nil {
|
||||
logger.Log.Error("OCR processing failed", zap.Error(err))
|
||||
return c.Send("❌ Ошибка обработки: " + err.Error())
|
||||
}
|
||||
|
||||
// 3. Анализ результатов для сообщения
|
||||
matchedCount := 0
|
||||
for _, item := range draft.Items {
|
||||
if item.IsMatched {
|
||||
@@ -134,29 +467,24 @@ func (bot *Bot) handlePhoto(c tele.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Формируем URL. Для Mini App это должен быть https URL вашего фронтенда.
|
||||
// Фронтенд должен уметь роутить /invoice/:id
|
||||
baseURL := strings.TrimRight(bot.webAppURL, "/")
|
||||
fullURL := fmt.Sprintf("%s/invoice/%s", baseURL, draft.ID.String())
|
||||
|
||||
// Формируем текст сообщения
|
||||
var msgText string
|
||||
if matchedCount == len(draft.Items) {
|
||||
msgText = fmt.Sprintf("✅ <b>Успех!</b> Все позиции (%d) распознаны.\n\nПереходите к созданию накладной.", len(draft.Items))
|
||||
} else {
|
||||
msgText = fmt.Sprintf("⚠️ <b>Внимание!</b> Распознано %d из %d позиций.\n\nНекоторые товары требуют ручного сопоставления. Нажмите кнопку ниже, чтобы исправить.", matchedCount, len(draft.Items))
|
||||
msgText = fmt.Sprintf("⚠️ <b>Внимание!</b> Распознано %d из %d позиций.\n\nНекоторые товары требуют ручного сопоставления.", matchedCount, len(draft.Items))
|
||||
}
|
||||
|
||||
menu := &tele.ReplyMarkup{}
|
||||
|
||||
// Используем WebApp, а не URL
|
||||
btnOpen := menu.WebApp("📝 Открыть накладную", &tele.WebApp{
|
||||
URL: fullURL,
|
||||
})
|
||||
|
||||
menu.Inline(
|
||||
menu.Row(btnOpen),
|
||||
)
|
||||
btnOpen := menu.WebApp("📝 Открыть накладную", &tele.WebApp{URL: fullURL})
|
||||
menu.Inline(menu.Row(btnOpen))
|
||||
|
||||
return c.Send(msgText, menu, tele.ModeHTML)
|
||||
}
|
||||
|
||||
func parseUUID(s string) uuid.UUID {
|
||||
id, _ := uuid.Parse(s)
|
||||
return id
|
||||
}
|
||||
|
||||
77
internal/transport/telegram/fsm.go
Normal file
77
internal/transport/telegram/fsm.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package telegram
|
||||
|
||||
import "sync"
|
||||
|
||||
// Состояния пользователя
|
||||
type State int
|
||||
|
||||
const (
|
||||
StateNone State = iota
|
||||
StateAddServerURL
|
||||
StateAddServerLogin
|
||||
StateAddServerPassword
|
||||
)
|
||||
|
||||
// UserContext хранит временные данные в процессе диалога
|
||||
type UserContext struct {
|
||||
State State
|
||||
TempURL string
|
||||
TempLogin string
|
||||
TempPassword string
|
||||
}
|
||||
|
||||
// StateManager управляет состояниями
|
||||
type StateManager struct {
|
||||
mu sync.RWMutex
|
||||
states map[int64]*UserContext
|
||||
}
|
||||
|
||||
func NewStateManager() *StateManager {
|
||||
return &StateManager{
|
||||
states: make(map[int64]*UserContext),
|
||||
}
|
||||
}
|
||||
|
||||
func (sm *StateManager) GetState(userID int64) State {
|
||||
sm.mu.RLock()
|
||||
defer sm.mu.RUnlock()
|
||||
if ctx, ok := sm.states[userID]; ok {
|
||||
return ctx.State
|
||||
}
|
||||
return StateNone
|
||||
}
|
||||
|
||||
func (sm *StateManager) SetState(userID int64, state State) {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
|
||||
if _, ok := sm.states[userID]; !ok {
|
||||
sm.states[userID] = &UserContext{}
|
||||
}
|
||||
sm.states[userID].State = state
|
||||
}
|
||||
|
||||
func (sm *StateManager) GetContext(userID int64) *UserContext {
|
||||
sm.mu.RLock()
|
||||
defer sm.mu.RUnlock()
|
||||
if ctx, ok := sm.states[userID]; ok {
|
||||
return ctx
|
||||
}
|
||||
return &UserContext{} // Return empty safe struct
|
||||
}
|
||||
|
||||
func (sm *StateManager) UpdateContext(userID int64, updater func(*UserContext)) {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
|
||||
if _, ok := sm.states[userID]; !ok {
|
||||
sm.states[userID] = &UserContext{}
|
||||
}
|
||||
updater(sm.states[userID])
|
||||
}
|
||||
|
||||
func (sm *StateManager) Reset(userID int64) {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
delete(sm.states, userID)
|
||||
}
|
||||
Reference in New Issue
Block a user