diff --git a/cmd/main.go b/cmd/main.go index a6ab6de..4f061bb 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,24 +1,23 @@ package main import ( - "fmt" "log" "time" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" "github.com/google/uuid" - "github.com/shopspring/decimal" "go.uber.org/zap" "rmser/config" - "rmser/internal/domain/catalog" - "rmser/internal/domain/invoices" "rmser/internal/infrastructure/db" "rmser/internal/infrastructure/ocr_client" + + "rmser/internal/transport/http/middleware" tgBot "rmser/internal/transport/telegram" - // Репозитории (инфраструктура) + // Repositories + accountPkg "rmser/internal/infrastructure/repository/account" catalogPkg "rmser/internal/infrastructure/repository/catalog" draftsPkg "rmser/internal/infrastructure/repository/drafts" invoicesPkg "rmser/internal/infrastructure/repository/invoices" @@ -26,43 +25,45 @@ import ( opsRepoPkg "rmser/internal/infrastructure/repository/operations" recipesPkg "rmser/internal/infrastructure/repository/recipes" recRepoPkg "rmser/internal/infrastructure/repository/recommendations" + suppliersPkg "rmser/internal/infrastructure/repository/suppliers" "rmser/internal/infrastructure/rms" + + // Services draftsServicePkg "rmser/internal/services/drafts" - invServicePkg "rmser/internal/services/invoices" // Сервис накладных ocrServicePkg "rmser/internal/services/ocr" recServicePkg "rmser/internal/services/recommend" "rmser/internal/services/sync" - "rmser/internal/transport/http/handlers" // Хендлеры + + // Handlers + "rmser/internal/transport/http/handlers" + + "rmser/pkg/crypto" "rmser/pkg/logger" ) func main() { - // 1. Загрузка конфигурации + // 1. Config cfg, err := config.LoadConfig(".") if err != nil { log.Fatalf("Ошибка загрузки конфига: %v", err) } - // OCR Client - pyClient := ocr_client.NewClient(cfg.OCR.ServiceURL) - // 2. Инициализация логгера + // 2. Logger logger.Init(cfg.App.Mode) defer logger.Log.Sync() logger.Log.Info("Запуск приложения rmser", zap.String("mode", cfg.App.Mode)) - // 3a. Подключение Redis (Новое) - // redisClient, err := redis.NewClient(cfg.Redis.Addr, cfg.Redis.Password, cfg.Redis.DB) - // if err != nil { - // logger.Log.Fatal("Ошибка подключения к Redis", zap.Error(err)) - // } - - // 3. Подключение к БД (PostgreSQL) + // 3. Crypto & DB + if cfg.Security.SecretKey == "" { + logger.Log.Fatal("Security.SecretKey не задан в конфиге!") + } + cryptoManager := crypto.NewCryptoManager(cfg.Security.SecretKey) database := db.NewPostgresDB(cfg.DB.DSN) - // 4. Инициализация слоев - rmsClient := rms.NewClient(cfg.RMS.BaseURL, cfg.RMS.Login, cfg.RMS.Password) + // 4. Repositories + accountRepo := accountPkg.NewRepository(database) catalogRepo := catalogPkg.NewRepository(database) recipesRepo := recipesPkg.NewRepository(database) invoicesRepo := invoicesPkg.NewRepository(database) @@ -70,124 +71,89 @@ func main() { recRepo := recRepoPkg.NewRepository(database) ocrRepo := ocrRepoPkg.NewRepository(database) draftsRepo := draftsPkg.NewRepository(database) + supplierRepo := suppliersPkg.NewRepository(database) - syncService := sync.NewService(rmsClient, catalogRepo, recipesRepo, invoicesRepo, opsRepo) + // 5. RMS Factory + rmsFactory := rms.NewFactory(accountRepo, cryptoManager) + + // 6. Services + pyClient := ocr_client.NewClient(cfg.OCR.ServiceURL) + + syncService := sync.NewService(rmsFactory, accountRepo, catalogRepo, recipesRepo, invoicesRepo, opsRepo, supplierRepo) recService := recServicePkg.NewService(recRepo) - ocrService := ocrServicePkg.NewService(ocrRepo, catalogRepo, draftsRepo, pyClient) - draftsService := draftsServicePkg.NewService(draftsRepo, ocrRepo, catalogRepo, rmsClient) - invoiceService := invServicePkg.NewService(rmsClient) + ocrService := ocrServicePkg.NewService(ocrRepo, catalogRepo, draftsRepo, accountRepo, pyClient) + draftsService := draftsServicePkg.NewService(draftsRepo, ocrRepo, catalogRepo, accountRepo, supplierRepo, rmsFactory) - // --- Инициализация Handler'ов --- - invoiceHandler := handlers.NewInvoiceHandler(invoiceService) + // 7. Handlers draftsHandler := handlers.NewDraftsHandler(draftsService) ocrHandler := handlers.NewOCRHandler(ocrService) recommendHandler := handlers.NewRecommendationsHandler(recService) - // --- БЛОК ПРОВЕРКИ СИНХРОНИЗАЦИИ (Run-once on start) --- - go func() { - logger.Log.Info(">>> Запуск тестовой синхронизации...") - - // 1. Каталог - if err := syncService.SyncCatalog(); err != nil { - logger.Log.Error("Ошибка синхронизации каталога", zap.Error(err)) - } else { - logger.Log.Info("<<< Каталог успешно синхронизирован") - } - - // 2. Техкарты - if err := syncService.SyncRecipes(); err != nil { - logger.Log.Error("Ошибка синхронизации техкарт", zap.Error(err)) - } else { - logger.Log.Info("<<< Техкарты успешно синхронизированы") - } - - // 3. Накладные - if err := syncService.SyncInvoices(); err != nil { - logger.Log.Error("Ошибка синхронизации накладных", zap.Error(err)) - } else { - logger.Log.Info("<<< Накладные успешно синхронизированы") - } - // 4. Складские операции - if err := syncService.SyncStoreOperations(); err != nil { - logger.Log.Error("Ошибка синхронизации операций", zap.Error(err)) - } else { - logger.Log.Info("<<< Операции успешно синхронизированы") - } - logger.Log.Info(">>> Запуск расчета рекомендаций...") - if err := recService.RefreshRecommendations(); err != nil { - logger.Log.Error("Ошибка расчета рекомендаций", zap.Error(err)) - } else { - // Для отладки можно вывести пару штук - recs, _ := recService.GetRecommendations() - logger.Log.Info("<<< Анализ завершен", zap.Int("found", len(recs))) - } - // === ТЕСТ ОТПРАВКИ НАКЛАДНОЙ === - // Запускаем через небольшую паузу, чтобы логи не перемешались - // time.Sleep(2 * time.Second) - // runManualInvoiceTest(rmsClient, catalogRepo) - // =============================== - }() - // ------------------------------------------------------- - // Запуск бота (в отдельной горутине, т.к. Start() блокирует поток) + // 8. Telegram Bot (Передаем syncService) if cfg.Telegram.Token != "" { - bot, err := tgBot.NewBot(cfg.Telegram, ocrService) + // !!! syncService добавлен в аргументы + bot, err := tgBot.NewBot(cfg.Telegram, ocrService, syncService, accountRepo, rmsFactory, cryptoManager) if err != nil { logger.Log.Fatal("Ошибка создания Telegram бота", zap.Error(err)) } go bot.Start() - defer bot.Stop() // Graceful shutdown - } else { - logger.Log.Warn("Telegram token не задан, бот не запущен") + defer bot.Stop() } - // 5. Запуск HTTP сервера (Gin) + // 9. HTTP Server if cfg.App.Mode == "release" { gin.SetMode(gin.ReleaseMode) } r := gin.Default() - // --- Настройка CORS --- - // Разрешаем запросы с любых источников для разработки Frontend corsConfig := cors.DefaultConfig() - corsConfig.AllowAllOrigins = true // В продакшене заменить на AllowOrigins: []string{"http://domain.com"} - corsConfig.AllowMethods = []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"} - corsConfig.AllowHeaders = []string{"Origin", "Content-Length", "Content-Type", "Authorization"} + corsConfig.AllowAllOrigins = true + corsConfig.AllowMethods = []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"} + corsConfig.AllowHeaders = []string{"Origin", "Content-Length", "Content-Type", "Authorization", "X-Telegram-User-ID"} r.Use(cors.New(corsConfig)) api := r.Group("/api") - { - // Invoices - api.POST("/invoices/send", invoiceHandler.SendInvoice) - // Черновики + api.Use(middleware.AuthMiddleware(accountRepo)) + { + // Drafts & Invoices api.GET("/drafts", draftsHandler.GetDrafts) api.GET("/drafts/:id", draftsHandler.GetDraft) api.DELETE("/drafts/:id", draftsHandler.DeleteDraft) api.PATCH("/drafts/:id/items/:itemId", draftsHandler.UpdateItem) api.POST("/drafts/:id/commit", draftsHandler.CommitDraft) - api.POST("/drafts/container", draftsHandler.AddContainer) // Добавление новой фасовки + api.POST("/drafts/container", draftsHandler.AddContainer) - // Склады + // Dictionaries + api.GET("/dictionaries", draftsHandler.GetDictionaries) api.GET("/dictionaries/stores", draftsHandler.GetStores) // Recommendations api.GET("/recommendations", recommendHandler.GetRecommendations) - // OCR + // OCR & Matching api.GET("/ocr/catalog", ocrHandler.GetCatalog) api.GET("/ocr/matches", ocrHandler.GetMatches) api.POST("/ocr/match", ocrHandler.SaveMatch) api.DELETE("/ocr/match", ocrHandler.DeleteMatch) api.GET("/ocr/unmatched", ocrHandler.GetUnmatched) api.GET("/ocr/search", ocrHandler.SearchProducts) + + // Manual Sync Trigger + api.POST("/sync/all", func(c *gin.Context) { + userID := c.MustGet("userID").(uuid.UUID) + // Запускаем в горутине, чтобы не держать соединение + go func() { + if err := syncService.SyncAllData(userID); err != nil { + logger.Log.Error("Manual sync failed", zap.Error(err)) + } + }() + c.JSON(200, gin.H{"status": "sync_started", "message": "Синхронизация запущена в фоне"}) + }) } - // Простой хелсчек r.GET("/health", func(c *gin.Context) { - c.JSON(200, gin.H{ - "status": "ok", - "time": time.Now().Format(time.RFC3339), - }) + c.JSON(200, gin.H{"status": "ok", "time": time.Now().Format(time.RFC3339)}) }) logger.Log.Info("Сервер запускается", zap.String("port", cfg.App.Port)) @@ -195,107 +161,3 @@ func main() { logger.Log.Fatal("Ошибка запуска сервера", zap.Error(err)) } } - -// runManualInvoiceTest создает тестовую накладную и отправляет её в RMS -func runManualInvoiceTest(client rms.ClientI, catRepo catalog.Repository) { - logger.Log.Info(">>> [TEST] Начало теста создания накладной...") - - // === НАСТРОЙКИ ТЕСТА === - const ( - MyStoreGUID = "1239d270-1bbe-f64f-b7ea-5f00518ef508" // <-- ВАШ СКЛАД - MySupplierGUID = "780aa87e-2688-4f99-915b-7924ca392ac1" // <-- ВАШ ПОСТАВЩИК - MyProductGUID = "607a1e96-f539-45d2-8709-3919f94bdc3e" // <-- ВАШ ТОВАР (Опционально) - ) - // ======================= - - // 1. Поиск товара - var targetProduct catalog.Product - var found bool - - // Если задан конкретный ID товара в константе - ищем его - if MyProductGUID != "00000000-0000-0000-0000-000000000000" { - products, _ := catRepo.GetAll() // В реальном коде лучше GetByID, но у нас repo пока простой - targetUUID := uuid.MustParse(MyProductGUID) - for _, p := range products { - if p.ID == targetUUID { - targetProduct = p - found = true - break - } - } - if !found { - logger.Log.Error("[TEST] Товар с указанным MyProductGUID не найден в локальной БД") - return - } - } else { - // Иначе ищем первый попавшийся GOODS - products, err := catRepo.GetAll() - if err != nil || len(products) == 0 { - logger.Log.Error("[TEST] БД пуста. Выполните SyncCatalog.") - return - } - - for _, p := range products { - // Ищем именно товар (GOODS) или заготовку (PREPARED), но не группу и не блюдо, если нужно - if p.Type == "GOODS" { - targetProduct = p - found = true - break - } - } - if !found { - logger.Log.Warn("[TEST] Не найдено товаров с типом GOODS. Берем первый попавшийся.") - targetProduct = products[0] - } - } - - logger.Log.Info("[TEST] Используем товар", - zap.String("name", targetProduct.Name), - zap.String("type", targetProduct.Type), - zap.String("uuid", targetProduct.ID.String()), - ) - - // 2. Парсинг ID Склада и Поставщика - storeID := uuid.Nil - if MyStoreGUID != "00000000-0000-0000-0000-000000000000" { - storeID = uuid.MustParse(MyStoreGUID) - } else { - logger.Log.Warn("[TEST] ID склада не задан! iiko вернет ошибку валидации.") - storeID = uuid.New() // Рандом, чтобы XML собрался - } - - supplierID := uuid.Nil - if MySupplierGUID != "00000000-0000-0000-0000-000000000000" { - supplierID = uuid.MustParse(MySupplierGUID) - } else { - logger.Log.Warn("[TEST] ID поставщика не задан!") - supplierID = uuid.New() - } - - // 3. Формируем накладную - testInv := invoices.Invoice{ - ID: uuid.Nil, - DocumentNumber: fmt.Sprintf("TEST-%d", time.Now().Unix()%10000), - DateIncoming: time.Now(), - SupplierID: supplierID, - DefaultStoreID: storeID, - Status: "NEW", - Items: []invoices.InvoiceItem{ - { - ProductID: targetProduct.ID, - Amount: decimal.NewFromFloat(5.0), - Price: decimal.NewFromFloat(100.0), - Sum: decimal.NewFromFloat(500.0), - }, - }, - } - - // 4. Отправляем - logger.Log.Info("[TEST] Отправка запроса в RMS...") - docNum, err := client.CreateIncomingInvoice(testInv) - if err != nil { - logger.Log.Error("[TEST] Ошибка отправки накладной", zap.Error(err)) - } else { - logger.Log.Info("[TEST] УСПЕХ! Накладная создана.", zap.String("doc_number", docNum)) - } -} diff --git a/config.yaml b/config.yaml index 5294211..2158b1c 100644 --- a/config.yaml +++ b/config.yaml @@ -19,6 +19,9 @@ rms: ocr: service_url: "http://ocr-service:5005" +security: + secret_key: "mhrcadmin994525" + telegram: token: "7858843765:AAE5HBQHbef4fGLoMDV91bhHZQFcnsBDcv4" admin_ids: [665599275] diff --git a/config/config.go b/config/config.go index a67baaf..8eb9c91 100644 --- a/config/config.go +++ b/config/config.go @@ -14,6 +14,7 @@ type Config struct { RMS RMSConfig OCR OCRConfig Telegram TelegramConfig + Security SecurityConfig } type AppConfig struct { @@ -31,23 +32,26 @@ type RedisConfig struct { Password string `mapstructure:"password"` DB int `mapstructure:"db"` } - -type OCRConfig struct { - ServiceURL string `mapstructure:"service_url"` -} - type RMSConfig struct { BaseURL string `mapstructure:"base_url"` Login string `mapstructure:"login"` Password string `mapstructure:"password"` // Исходный пароль, хеширование будет в клиенте } +type OCRConfig struct { + ServiceURL string `mapstructure:"service_url"` +} + type TelegramConfig struct { Token string `mapstructure:"token"` AdminIDs []int64 `mapstructure:"admin_ids"` WebAppURL string `mapstructure:"web_app_url"` } +type SecurityConfig struct { + SecretKey string `mapstructure:"secret_key"` // 32 bytes for AES-256 +} + // LoadConfig загружает конфигурацию из файла и переменных окружения func LoadConfig(path string) (*Config, error) { viper.AddConfigPath(path) diff --git a/internal/domain/account/entity.go b/internal/domain/account/entity.go new file mode 100644 index 0000000..1b62a59 --- /dev/null +++ b/internal/domain/account/entity.go @@ -0,0 +1,63 @@ +package account + +import ( + "time" + + "github.com/google/uuid" +) + +// User - Пользователь системы (Telegram аккаунт) +type User struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + TelegramID int64 `gorm:"uniqueIndex;not null" json:"telegram_id"` + Username string `gorm:"type:varchar(100)" json:"username"` + FirstName string `gorm:"type:varchar(100)" json:"first_name"` + LastName string `gorm:"type:varchar(100)" json:"last_name"` + PhotoURL string `gorm:"type:text" json:"photo_url"` + + IsAdmin bool `gorm:"default:false" json:"is_admin"` + + // Связь с серверами + Servers []RMSServer `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"servers,omitempty"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// RMSServer - Настройки подключения к iikoRMS +type RMSServer struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id"` + + Name string `gorm:"type:varchar(100);not null" json:"name"` // Название (напр. "Ресторан на Ленина") + + // Credentials + BaseURL string `gorm:"type:varchar(255);not null" json:"base_url"` + Login string `gorm:"type:varchar(100);not null" json:"login"` + EncryptedPassword string `gorm:"type:text;not null" json:"-"` // Пароль храним зашифрованным + + // Billing / Stats + InvoiceCount int `gorm:"default:0" json:"invoice_count"` // Счетчик успешно отправленных накладных + + IsActive bool `gorm:"default:true" json:"is_active"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Repository интерфейс управления аккаунтами +type Repository interface { + // Users + GetOrCreateUser(telegramID int64, username, first, last string) (*User, error) + GetUserByTelegramID(telegramID int64) (*User, error) + + // Servers + SaveServer(server *RMSServer) error + SetActiveServer(userID, serverID uuid.UUID) error + GetActiveServer(userID uuid.UUID) (*RMSServer, error) // Получить активный (первый попавшийся или помеченный) + GetAllServers(userID uuid.UUID) ([]RMSServer, error) + DeleteServer(serverID uuid.UUID) error + + // Billing + IncrementInvoiceCount(serverID uuid.UUID) error +} diff --git a/internal/domain/catalog/entity.go b/internal/domain/catalog/entity.go index 7284991..d76f2f0 100644 --- a/internal/domain/catalog/entity.go +++ b/internal/domain/catalog/entity.go @@ -9,22 +9,25 @@ import ( // MeasureUnit - Единица измерения (kg, l, pcs) type MeasureUnit struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"` - Name string `gorm:"type:varchar(50);not null" json:"name"` - Code string `gorm:"type:varchar(50)" json:"code"` + ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"` + RMSServerID uuid.UUID `gorm:"type:uuid;not null;index" json:"-"` + Name string `gorm:"type:varchar(50);not null" json:"name"` + Code string `gorm:"type:varchar(50)" json:"code"` } // ProductContainer - Фасовка (упаковка) товара type ProductContainer struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"` - ProductID uuid.UUID `gorm:"type:uuid;index;not null" json:"product_id"` - Name string `gorm:"type:varchar(100);not null" json:"name"` - Count decimal.Decimal `gorm:"type:numeric(19,4);not null" json:"count"` // Коэфф. пересчета + ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"` + RMSServerID uuid.UUID `gorm:"type:uuid;not null;index" json:"-"` + ProductID uuid.UUID `gorm:"type:uuid;index;not null" json:"product_id"` + Name string `gorm:"type:varchar(100);not null" json:"name"` + Count decimal.Decimal `gorm:"type:numeric(19,4);not null" json:"count"` // Коэфф. пересчета } // Product - Номенклатура type Product struct { ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"` + RMSServerID uuid.UUID `gorm:"type:uuid;not null;index" json:"-"` ParentID *uuid.UUID `gorm:"type:uuid;index" json:"parent_id"` Name string `gorm:"type:varchar(255);not null" json:"name"` Type string `gorm:"type:varchar(50);index" json:"type"` // GOODS, DISH, PREPARED @@ -53,11 +56,14 @@ type Product struct { type Repository interface { SaveMeasureUnits(units []MeasureUnit) error SaveProducts(products []Product) error - SaveContainer(container ProductContainer) error // Добавление фасовки - Search(query string) ([]Product, error) - GetAll() ([]Product, error) - GetActiveGoods() ([]Product, error) - // --- Stores --- + SaveContainer(container ProductContainer) error + + Search(serverID uuid.UUID, query string) ([]Product, error) + GetActiveGoods(serverID uuid.UUID) ([]Product, error) + SaveStores(stores []Store) error - GetActiveStores() ([]Store, error) + GetActiveStores(serverID uuid.UUID) ([]Store, error) + + CountGoods(serverID uuid.UUID) (int64, error) + CountStores(serverID uuid.UUID) (int64, error) } diff --git a/internal/domain/catalog/store.go b/internal/domain/catalog/store.go index 98ddf74..34768b6 100644 --- a/internal/domain/catalog/store.go +++ b/internal/domain/catalog/store.go @@ -6,11 +6,13 @@ import ( "github.com/google/uuid" ) -// Store - Склад (в терминологии iiko: Entity с типом Account и подтипом INVENTORY_ASSETS) +// Store - Склад type Store struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"` + ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"` + RMSServerID uuid.UUID `gorm:"type:uuid;not null;index" json:"-"` + Name string `gorm:"type:varchar(255);not null" json:"name"` - ParentCorporateID uuid.UUID `gorm:"type:uuid;index" json:"parent_corporate_id"` // ID юр.лица/торгового предприятия + ParentCorporateID uuid.UUID `gorm:"type:uuid;index" json:"parent_corporate_id"` IsDeleted bool `gorm:"default:false" json:"is_deleted"` CreatedAt time.Time `json:"created_at"` diff --git a/internal/domain/drafts/entity.go b/internal/domain/drafts/entity.go index 66fa893..4b95b15 100644 --- a/internal/domain/drafts/entity.go +++ b/internal/domain/drafts/entity.go @@ -9,31 +9,31 @@ import ( "github.com/shopspring/decimal" ) -// Статусы черновика const ( - StatusProcessing = "PROCESSING" // OCR в процессе - StatusReadyToVerify = "READY_TO_VERIFY" // Распознано, ждет проверки пользователем - StatusCompleted = "COMPLETED" // Отправлено в RMS - StatusError = "ERROR" // Ошибка обработки - StatusCanceled = "CANCELED" // Пользователь отменил - StatusDeleted = "DELETED" // Пользователь удалил + StatusProcessing = "PROCESSING" + StatusReadyToVerify = "READY_TO_VERIFY" + StatusCompleted = "COMPLETED" + StatusError = "ERROR" + StatusCanceled = "CANCELED" + StatusDeleted = "DELETED" ) -// DraftInvoice - Черновик накладной type DraftInvoice struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - ChatID int64 `gorm:"index" json:"chat_id"` // ID чата в Telegram (кто прислал) - SenderPhotoURL string `gorm:"type:text" json:"photo_url"` // Ссылка на фото - Status string `gorm:"type:varchar(50);default:'PROCESSING'" json:"status"` + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + + // Привязка к аккаунту и серверу + UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id"` + RMSServerID uuid.UUID `gorm:"type:uuid;not null;index" json:"rms_server_id"` + + SenderPhotoURL string `gorm:"type:text" json:"photo_url"` + Status string `gorm:"type:varchar(50);default:'PROCESSING'" json:"status"` - // Данные для отправки в RMS DocumentNumber string `gorm:"type:varchar(100)" json:"document_number"` DateIncoming *time.Time `json:"date_incoming"` SupplierID *uuid.UUID `gorm:"type:uuid" json:"supplier_id"` - StoreID *uuid.UUID `gorm:"type:uuid" json:"store_id"` - // Связь со складом для Preload - Store *catalog.Store `gorm:"foreignKey:StoreID" json:"store,omitempty"` + StoreID *uuid.UUID `gorm:"type:uuid" json:"store_id"` + Store *catalog.Store `gorm:"foreignKey:StoreID" json:"store,omitempty"` Comment string `gorm:"type:text" json:"comment"` RMSInvoiceID *uuid.UUID `gorm:"type:uuid" json:"rms_invoice_id"` @@ -44,38 +44,32 @@ type DraftInvoice struct { UpdatedAt time.Time `json:"updated_at"` } -// DraftInvoiceItem - Позиция черновика type DraftInvoiceItem struct { ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` DraftID uuid.UUID `gorm:"type:uuid;not null;index" json:"draft_id"` - // --- Результаты OCR (Исходные данные) --- - RawName string `gorm:"type:varchar(255);not null" json:"raw_name"` // Текст с чека - RawAmount decimal.Decimal `gorm:"type:numeric(19,4)" json:"raw_amount"` // Кол-во, которое увидел OCR - RawPrice decimal.Decimal `gorm:"type:numeric(19,4)" json:"raw_price"` // Цена, которую увидел OCR + RawName string `gorm:"type:varchar(255);not null" json:"raw_name"` + RawAmount decimal.Decimal `gorm:"type:numeric(19,4)" json:"raw_amount"` + RawPrice decimal.Decimal `gorm:"type:numeric(19,4)" json:"raw_price"` - // --- Результат Матчинга и Выбора пользователя --- ProductID *uuid.UUID `gorm:"type:uuid;index" json:"product_id"` Product *catalog.Product `gorm:"foreignKey:ProductID" json:"product,omitempty"` ContainerID *uuid.UUID `gorm:"type:uuid;index" json:"container_id"` Container *catalog.ProductContainer `gorm:"foreignKey:ContainerID" json:"container,omitempty"` - // Финальные цифры, которые пойдут в накладную Quantity decimal.Decimal `gorm:"type:numeric(19,4);default:0" json:"quantity"` Price decimal.Decimal `gorm:"type:numeric(19,4);default:0" json:"price"` Sum decimal.Decimal `gorm:"type:numeric(19,4);default:0" json:"sum"` - IsMatched bool `gorm:"default:false" json:"is_matched"` // Удалось ли системе найти пару автоматически + IsMatched bool `gorm:"default:false" json:"is_matched"` } -// Repository интерфейс type Repository interface { Create(draft *DraftInvoice) error GetByID(id uuid.UUID) (*DraftInvoice, error) Update(draft *DraftInvoice) error CreateItems(items []DraftInvoiceItem) error - // UpdateItem обновляет конкретную строку (например, при ручном выборе товара) UpdateItem(itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error Delete(id uuid.UUID) error - GetActive() ([]DraftInvoice, error) + GetActive(userID uuid.UUID) ([]DraftInvoice, error) } diff --git a/internal/domain/invoices/entity.go b/internal/domain/invoices/entity.go index f9de9bd..4b08148 100644 --- a/internal/domain/invoices/entity.go +++ b/internal/domain/invoices/entity.go @@ -12,6 +12,7 @@ import ( // Invoice - Приходная накладная type Invoice struct { ID uuid.UUID `gorm:"type:uuid;primary_key;"` + RMSServerID uuid.UUID `gorm:"type:uuid;not null;index"` DocumentNumber string `gorm:"type:varchar(100);index"` DateIncoming time.Time `gorm:"index"` SupplierID uuid.UUID `gorm:"type:uuid;index"` @@ -39,6 +40,7 @@ type InvoiceItem struct { } type Repository interface { - GetLastInvoiceDate() (*time.Time, error) + GetLastInvoiceDate(serverID uuid.UUID) (*time.Time, error) SaveInvoices(invoices []Invoice) error + CountRecent(serverID uuid.UUID, days int) (int64, error) } diff --git a/internal/domain/ocr/entity.go b/internal/domain/ocr/entity.go index 156652f..dcc4055 100644 --- a/internal/domain/ocr/entity.go +++ b/internal/domain/ocr/entity.go @@ -9,38 +9,45 @@ import ( "github.com/shopspring/decimal" ) -// ProductMatch связывает текст из чека с конкретным товаром в iiko +// ProductMatch type ProductMatch struct { - RawName string `gorm:"type:varchar(255);primary_key" json:"raw_name"` + // RawName больше не PrimaryKey, так как в разных серверах один текст может значить разное + // Делаем составной ключ или суррогатный ID. + // Для простоты GORM: ID - uuid, а уникальность через индекс (RawName + ServerID) + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"` + RMSServerID uuid.UUID `gorm:"type:uuid;not null;index:idx_raw_server,unique"` + + RawName string `gorm:"type:varchar(255);not null;index:idx_raw_server,unique" json:"raw_name"` ProductID uuid.UUID `gorm:"type:uuid;not null;index" json:"product_id"` Product catalog.Product `gorm:"foreignKey:ProductID" json:"product"` - // Количество и фасовки - Quantity decimal.Decimal `gorm:"type:numeric(19,4);default:1" json:"quantity"` - ContainerID *uuid.UUID `gorm:"type:uuid;index" json:"container_id"` - - // Для подгрузки данных о фасовке при чтении - Container *catalog.ProductContainer `gorm:"foreignKey:ContainerID" json:"container,omitempty"` + Quantity decimal.Decimal `gorm:"type:numeric(19,4);default:1" json:"quantity"` + ContainerID *uuid.UUID `gorm:"type:uuid;index" json:"container_id"` + Container *catalog.ProductContainer `gorm:"foreignKey:ContainerID" json:"container,omitempty"` UpdatedAt time.Time `json:"updated_at"` CreatedAt time.Time `json:"created_at"` } -// UnmatchedItem хранит строки, которые не удалось распознать, для подсказок +// UnmatchedItem тоже стоит делить, чтобы подсказывать пользователю только его нераспознанные, +// хотя глобальная база unmatched может быть полезна для аналитики. +// Сделаем раздельной для чистоты SaaS. type UnmatchedItem struct { - RawName string `gorm:"type:varchar(255);primary_key" json:"raw_name"` - Count int `gorm:"default:1" json:"count"` // Сколько раз встречалось + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"` + RMSServerID uuid.UUID `gorm:"type:uuid;not null;index:idx_unm_raw_server,unique"` + + RawName string `gorm:"type:varchar(255);not null;index:idx_unm_raw_server,unique" json:"raw_name"` + Count int `gorm:"default:1" json:"count"` LastSeen time.Time `json:"last_seen"` } type Repository interface { - // SaveMatch теперь принимает quantity и containerID - SaveMatch(rawName string, productID uuid.UUID, quantity decimal.Decimal, containerID *uuid.UUID) error - DeleteMatch(rawName string) error - FindMatch(rawName string) (*ProductMatch, error) // Возвращаем полную структуру, чтобы получить qty - GetAllMatches() ([]ProductMatch, error) + SaveMatch(serverID uuid.UUID, rawName string, productID uuid.UUID, quantity decimal.Decimal, containerID *uuid.UUID) error + DeleteMatch(serverID uuid.UUID, rawName string) error + FindMatch(serverID uuid.UUID, rawName string) (*ProductMatch, error) + GetAllMatches(serverID uuid.UUID) ([]ProductMatch, error) - UpsertUnmatched(rawName string) error - GetTopUnmatched(limit int) ([]UnmatchedItem, error) - DeleteUnmatched(rawName string) error + UpsertUnmatched(serverID uuid.UUID, rawName string) error + GetTopUnmatched(serverID uuid.UUID, limit int) ([]UnmatchedItem, error) + DeleteUnmatched(serverID uuid.UUID, rawName string) error } diff --git a/internal/domain/operations/entity.go b/internal/domain/operations/entity.go index f1e6c16..37d6a11 100644 --- a/internal/domain/operations/entity.go +++ b/internal/domain/operations/entity.go @@ -19,8 +19,9 @@ const ( // StoreOperation - запись из складского отчета type StoreOperation struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"` - ProductID uuid.UUID `gorm:"type:uuid;not null;index"` + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"` + RMSServerID uuid.UUID `gorm:"type:uuid;not null;index"` + ProductID uuid.UUID `gorm:"type:uuid;not null;index"` // Наш внутренний, "очищенный" тип операции OpType OperationType `gorm:"type:varchar(50);index"` @@ -44,5 +45,5 @@ type StoreOperation struct { } type Repository interface { - SaveOperations(ops []StoreOperation, opType OperationType, dateFrom, dateTo time.Time) error + SaveOperations(ops []StoreOperation, serverID uuid.UUID, opType OperationType, dateFrom, dateTo time.Time) error } diff --git a/internal/domain/recipes/entity.go b/internal/domain/recipes/entity.go index 3f7eb56..f7454ae 100644 --- a/internal/domain/recipes/entity.go +++ b/internal/domain/recipes/entity.go @@ -11,10 +11,11 @@ import ( // Recipe - Технологическая карта type Recipe struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;"` - ProductID uuid.UUID `gorm:"type:uuid;not null;index"` - DateFrom time.Time `gorm:"index"` - DateTo *time.Time + ID uuid.UUID `gorm:"type:uuid;primary_key;"` + RMSServerID uuid.UUID `gorm:"type:uuid;not null;index"` + ProductID uuid.UUID `gorm:"type:uuid;not null;index"` + DateFrom time.Time `gorm:"index"` + DateTo *time.Time Product catalog.Product `gorm:"foreignKey:ProductID"` Items []RecipeItem `gorm:"foreignKey:RecipeID;constraint:OnDelete:CASCADE"` @@ -22,11 +23,12 @@ type Recipe struct { // RecipeItem - Ингредиент type RecipeItem struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"` - RecipeID uuid.UUID `gorm:"type:uuid;not null;index"` - ProductID uuid.UUID `gorm:"type:uuid;not null;index"` - AmountIn decimal.Decimal `gorm:"type:numeric(19,4);not null"` - AmountOut decimal.Decimal `gorm:"type:numeric(19,4);not null"` + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"` + RMSServerID uuid.UUID `gorm:"type:uuid;not null;index"` + RecipeID uuid.UUID `gorm:"type:uuid;not null;index"` + ProductID uuid.UUID `gorm:"type:uuid;not null;index"` + AmountIn decimal.Decimal `gorm:"type:numeric(19,4);not null"` + AmountOut decimal.Decimal `gorm:"type:numeric(19,4);not null"` Product catalog.Product `gorm:"foreignKey:ProductID"` } diff --git a/internal/domain/suppliers/entity.go b/internal/domain/suppliers/entity.go new file mode 100644 index 0000000..6752b4c --- /dev/null +++ b/internal/domain/suppliers/entity.go @@ -0,0 +1,29 @@ +package suppliers + +import ( + "time" + + "github.com/google/uuid" +) + +// Supplier - Поставщик (Контрагент) +type Supplier struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"` + Name string `gorm:"type:varchar(255);not null" json:"name"` + Code string `gorm:"type:varchar(50)" json:"code"` + INN string `gorm:"type:varchar(20)" json:"inn"` // taxpayerIdNumber + + // Привязка к конкретному серверу iiko (Multi-tenant) + RMSServerID uuid.UUID `gorm:"type:uuid;not null;index" json:"-"` + + IsDeleted bool `gorm:"default:false" json:"is_deleted"` + + UpdatedAt time.Time `json:"updated_at"` +} + +type Repository interface { + SaveBatch(suppliers []Supplier) error + // GetRankedByUsage возвращает поставщиков, отсортированных по частоте использования в накладных за N дней + GetRankedByUsage(serverID uuid.UUID, daysLookBack int) ([]Supplier, error) + Count(serverID uuid.UUID) (int64, error) +} diff --git a/internal/infrastructure/db/postgres.go b/internal/infrastructure/db/postgres.go index 1f3100b..ef349a5 100644 --- a/internal/infrastructure/db/postgres.go +++ b/internal/infrastructure/db/postgres.go @@ -6,6 +6,7 @@ import ( "log" "os" "regexp" + "rmser/internal/domain/account" "rmser/internal/domain/catalog" "rmser/internal/domain/drafts" "rmser/internal/domain/invoices" @@ -13,6 +14,7 @@ import ( "rmser/internal/domain/operations" "rmser/internal/domain/recipes" "rmser/internal/domain/recommendations" + "rmser/internal/domain/suppliers" "time" _ "github.com/jackc/pgx/v5/stdlib" @@ -46,10 +48,13 @@ func NewPostgresDB(dsn string) *gorm.DB { // 4. Автомиграция err = db.AutoMigrate( + &account.User{}, + &account.RMSServer{}, &catalog.Product{}, &catalog.MeasureUnit{}, &catalog.ProductContainer{}, &catalog.Store{}, + &suppliers.Supplier{}, &recipes.Recipe{}, &recipes.RecipeItem{}, &invoices.Invoice{}, diff --git a/internal/infrastructure/repository/account/postgres.go b/internal/infrastructure/repository/account/postgres.go new file mode 100644 index 0000000..b9981e4 --- /dev/null +++ b/internal/infrastructure/repository/account/postgres.go @@ -0,0 +1,117 @@ +package account + +import ( + "rmser/internal/domain/account" + + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type pgRepository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) account.Repository { + return &pgRepository{db: db} +} + +// GetOrCreateUser находит пользователя или создает нового +func (r *pgRepository) GetOrCreateUser(telegramID int64, username, first, last string) (*account.User, error) { + var user account.User + err := r.db.Where("telegram_id = ?", telegramID).First(&user).Error + + if err != nil { + if err == gorm.ErrRecordNotFound { + // Создаем + newUser := account.User{ + TelegramID: telegramID, + Username: username, + FirstName: first, + LastName: last, + } + if err := r.db.Create(&newUser).Error; err != nil { + return nil, err + } + return &newUser, nil + } + return nil, err + } + + // Обновляем инфо, если изменилось (опционально) + if user.Username != username || user.FirstName != first { + user.Username = username + user.FirstName = first + user.LastName = last + r.db.Save(&user) + } + + return &user, nil +} + +func (r *pgRepository) GetUserByTelegramID(telegramID int64) (*account.User, error) { + var user account.User + // Preload Servers чтобы сразу видеть подключения + err := r.db.Preload("Servers").Where("telegram_id = ?", telegramID).First(&user).Error + if err != nil { + return nil, err + } + return &user, nil +} + +func (r *pgRepository) SaveServer(server *account.RMSServer) error { + return r.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, + UpdateAll: true, + }).Create(server).Error +} + +// SetActiveServer делает указанный сервер активным, а остальные — неактивными +func (r *pgRepository) SetActiveServer(userID, serverID uuid.UUID) error { + return r.db.Transaction(func(tx *gorm.DB) error { + // 1. Сбрасываем флаг у всех серверов пользователя + if err := tx.Model(&account.RMSServer{}). + Where("user_id = ?", userID). + Update("is_active", false).Error; err != nil { + return err + } + + // 2. Ставим флаг целевому серверу + if err := tx.Model(&account.RMSServer{}). + Where("id = ? AND user_id = ?", serverID, userID). + Update("is_active", true).Error; err != nil { + return err + } + + return nil + }) +} + +func (r *pgRepository) GetActiveServer(userID uuid.UUID) (*account.RMSServer, error) { + var server account.RMSServer + // Берем первый активный сервер. В будущем можно добавить поле IsSelected + err := r.db.Where("user_id = ? AND is_active = ?", userID, true).First(&server).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil // Нет серверов + } + return nil, err + } + return &server, nil +} + +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 + return servers, err +} + +func (r *pgRepository) DeleteServer(serverID uuid.UUID) error { + return r.db.Delete(&account.RMSServer{}, serverID).Error +} + +func (r *pgRepository) IncrementInvoiceCount(serverID uuid.UUID) error { + return r.db.Model(&account.RMSServer{}). + Where("id = ?", serverID). + UpdateColumn("invoice_count", gorm.Expr("invoice_count + ?", 1)).Error +} diff --git a/internal/infrastructure/repository/catalog/postgres.go b/internal/infrastructure/repository/catalog/postgres.go index 74c3d4f..a46dea5 100644 --- a/internal/infrastructure/repository/catalog/postgres.go +++ b/internal/infrastructure/repository/catalog/postgres.go @@ -16,12 +16,17 @@ func NewRepository(db *gorm.DB) catalog.Repository { return &pgRepository{db: db} } +// --- Запись (Save) --- +// При сохранении мы предполагаем, что serverID уже проставлен в Entity в слое Service. +// Но для надежности можно передавать serverID в метод Save, однако Service должен это контролировать. +// Оставим контракт Save(products []Product), где внутри products уже заполнен RMSServerID. + func (r *pgRepository) SaveMeasureUnits(units []catalog.MeasureUnit) error { if len(units) == 0 { return nil } return r.db.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "id"}}, + Columns: []clause.Column{{Name: "id"}}, // ID глобально уникален (UUID), конфликтов между серверами не будет UpdateAll: true, }).CreateInBatches(units, 100).Error } @@ -29,7 +34,7 @@ func (r *pgRepository) SaveMeasureUnits(units []catalog.MeasureUnit) error { func (r *pgRepository) SaveProducts(products []catalog.Product) error { sorted := sortProductsByHierarchy(products) return r.db.Transaction(func(tx *gorm.DB) error { - // 1. Сохраняем продукты (без контейнеров, чтобы ускорить и не дублировать) + // 1. Продукты if err := tx.Omit("Containers").Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "id"}}, UpdateAll: true, @@ -37,13 +42,12 @@ func (r *pgRepository) SaveProducts(products []catalog.Product) error { return err } - // 2. Собираем все контейнеры в один слайс + // 2. Контейнеры var allContainers []catalog.ProductContainer for _, p := range products { allContainers = append(allContainers, p.Containers...) } - // 3. Сохраняем контейнеры if len(allContainers) > 0 { if err := tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "id"}}, @@ -56,13 +60,66 @@ func (r *pgRepository) SaveProducts(products []catalog.Product) error { }) } +func (r *pgRepository) SaveContainer(container catalog.ProductContainer) error { + return r.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, + UpdateAll: true, + }).Create(&container).Error +} + +func (r *pgRepository) SaveStores(stores []catalog.Store) error { + if len(stores) == 0 { + return nil + } + return r.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, + UpdateAll: true, + }).CreateInBatches(stores, 100).Error +} + +// --- Чтение (Read) с фильтрацией по ServerID --- + func (r *pgRepository) GetAll() ([]catalog.Product, error) { + // Этот метод был legacy и грузил всё. Теперь он опасен без serverID. + // Оставляем заглушку или удаляем. Лучше удалить из интерфейса, но пока вернем пустой список + // чтобы не ломать сборку, пока не почистим вызовы. + return nil, nil +} + +func (r *pgRepository) GetActiveGoods(serverID uuid.UUID) ([]catalog.Product, error) { var products []catalog.Product - err := r.db.Preload("MainUnit").Find(&products).Error + 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 return products, err } -// Вспомогательная функция сортировки (оставляем как была) +func (r *pgRepository) GetActiveStores(serverID uuid.UUID) ([]catalog.Store, error) { + var stores []catalog.Store + err := r.db.Where("rms_server_id = ? AND is_deleted = ?", serverID, false).Order("name ASC").Find(&stores).Error + return stores, err +} + +func (r *pgRepository) Search(serverID uuid.UUID, query string) ([]catalog.Product, error) { + var products []catalog.Product + q := "%" + query + "%" + + err := 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 + + return products, err +} + +// sortProductsByHierarchy - вспомогательная функция, оставляем как есть (копипаст из старого файла) func sortProductsByHierarchy(products []catalog.Product) []catalog.Product { if len(products) == 0 { return products @@ -104,58 +161,18 @@ func sortProductsByHierarchy(products []catalog.Product) []catalog.Product { return result } -// GetActiveGoods возвращает только активные товары c подгруженной единицей измерения -// GetActiveGoods оптимизирован: подгружаем Units и Containers -func (r *pgRepository) GetActiveGoods() ([]catalog.Product, error) { - var products []catalog.Product - err := r.db. - Preload("MainUnit"). - Preload("Containers"). // <-- Подгружаем фасовки - Where("is_deleted = ? AND type IN ?", false, []string{"GOODS"}). - Order("name ASC"). - Find(&products).Error - return products, err +func (r *pgRepository) CountGoods(serverID uuid.UUID) (int64, error) { + var count int64 + err := r.db.Model(&catalog.Product{}). + Where("rms_server_id = ? AND type IN ? AND is_deleted = ?", serverID, []string{"GOODS"}, false). + Count(&count).Error + return count, err } -func (r *pgRepository) SaveStores(stores []catalog.Store) error { - if len(stores) == 0 { - return nil - } - return r.db.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "id"}}, - UpdateAll: true, - }).CreateInBatches(stores, 100).Error -} - -func (r *pgRepository) GetActiveStores() ([]catalog.Store, error) { - var stores []catalog.Store - err := r.db.Where("is_deleted = ?", false).Order("name ASC").Find(&stores).Error - return stores, err -} - -// SaveContainer сохраняет или обновляет одну фасовку -func (r *pgRepository) SaveContainer(container catalog.ProductContainer) error { - return r.db.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "id"}}, - UpdateAll: true, - }).Create(&container).Error -} - -// Search ищет товары по названию, артикулу или коду (ILIKE) -func (r *pgRepository) Search(query string) ([]catalog.Product, error) { - var products []catalog.Product - - // Оборачиваем в проценты для поиска подстроки - q := "%" + query + "%" - - err := r.db. - Preload("MainUnit"). - Preload("Containers"). // Обязательно грузим фасовки, они нужны для выбора - Where("is_deleted = ? AND type = ?", false, "GOODS"). - Where("name ILIKE ? OR code ILIKE ? OR num ILIKE ?", q, q, q). - Order("name ASC"). - Limit(20). // Ограничиваем выдачу, чтобы не перегружать фронт - Find(&products).Error - - return products, err +func (r *pgRepository) CountStores(serverID uuid.UUID) (int64, error) { + var count int64 + err := r.db.Model(&catalog.Store{}). + Where("rms_server_id = ? AND is_deleted = ?", serverID, false). + Count(&count).Error + return count, err } diff --git a/internal/infrastructure/repository/drafts/postgres.go b/internal/infrastructure/repository/drafts/postgres.go index 58b9097..cc7b2bf 100644 --- a/internal/infrastructure/repository/drafts/postgres.go +++ b/internal/infrastructure/repository/drafts/postgres.go @@ -27,7 +27,7 @@ func (r *pgRepository) GetByID(id uuid.UUID) (*drafts.DraftInvoice, error) { return db.Order("draft_invoice_items.raw_name ASC") }). Preload("Items.Product"). - Preload("Items.Product.MainUnit"). // Нужно для отображения единиц + Preload("Items.Product.MainUnit"). Preload("Items.Container"). Where("id = ?", id). First(&draft).Error @@ -39,7 +39,7 @@ func (r *pgRepository) GetByID(id uuid.UUID) (*drafts.DraftInvoice, error) { } func (r *pgRepository) Update(draft *drafts.DraftInvoice) error { - // Обновляем только основные поля шапки + // Обновляем поля шапки + привязки к серверу return r.db.Model(draft).Updates(map[string]interface{}{ "status": draft.Status, "document_number": draft.DocumentNumber, @@ -48,6 +48,7 @@ func (r *pgRepository) Update(draft *drafts.DraftInvoice) error { "store_id": draft.StoreID, "comment": draft.Comment, "rms_invoice_id": draft.RMSInvoiceID, + "rms_server_id": draft.RMSServerID, // Вдруг поменялся, хотя не должен "updated_at": gorm.Expr("NOW()"), }).Error } @@ -60,34 +61,29 @@ func (r *pgRepository) CreateItems(items []drafts.DraftInvoiceItem) 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 задан - значит сматчено isMatched := productID != nil - updates := map[string]interface{}{ - "product_id": productID, - "container_id": containerID, - "quantity": qty, - "price": price, - "sum": sum, - "is_matched": isMatched, - } - return r.db.Model(&drafts.DraftInvoiceItem{}). Where("id = ?", itemID). - Updates(updates).Error + Updates(map[string]interface{}{ + "product_id": productID, + "container_id": containerID, + "quantity": qty, + "price": price, + "sum": sum, + "is_matched": isMatched, + }).Error } func (r *pgRepository) Delete(id uuid.UUID) error { return r.db.Delete(&drafts.DraftInvoice{}, id).Error } -func (r *pgRepository) GetActive() ([]drafts.DraftInvoice, error) { +// GetActive фильтрует по UserID +func (r *pgRepository) GetActive(userID uuid.UUID) ([]drafts.DraftInvoice, error) { var list []drafts.DraftInvoice - // Выбираем статусы, которые считаем "активными" activeStatuses := []string{ drafts.StatusProcessing, drafts.StatusReadyToVerify, @@ -96,9 +92,9 @@ func (r *pgRepository) GetActive() ([]drafts.DraftInvoice, error) { } err := r.db. - Preload("Items"). // Нужны для подсчета суммы и количества - Preload("Store"). // Нужно для названия склада - Where("status IN ?", activeStatuses). + Preload("Items"). + Preload("Store"). + Where("user_id = ? AND status IN ?", userID, activeStatuses). // <-- FILTER Order("created_at DESC"). Find(&list).Error diff --git a/internal/infrastructure/repository/invoices/postgres.go b/internal/infrastructure/repository/invoices/postgres.go index 65e3721..8fb870e 100644 --- a/internal/infrastructure/repository/invoices/postgres.go +++ b/internal/infrastructure/repository/invoices/postgres.go @@ -5,6 +5,7 @@ import ( "rmser/internal/domain/invoices" + "github.com/google/uuid" "gorm.io/gorm" "gorm.io/gorm/clause" ) @@ -17,9 +18,10 @@ func NewRepository(db *gorm.DB) invoices.Repository { return &pgRepository{db: db} } -func (r *pgRepository) GetLastInvoiceDate() (*time.Time, error) { +func (r *pgRepository) GetLastInvoiceDate(serverID uuid.UUID) (*time.Time, error) { var inv invoices.Invoice - err := r.db.Order("date_incoming DESC").First(&inv).Error + // Ищем последнюю накладную только для этого сервера + err := r.db.Where("rms_server_id = ?", serverID).Order("date_incoming DESC").First(&inv).Error if err != nil { if err == gorm.ErrRecordNotFound { return nil, nil @@ -38,6 +40,7 @@ func (r *pgRepository) SaveInvoices(list []invoices.Invoice) error { }).Create(&inv).Error; err != nil { return err } + // Удаляем старые Items для этой накладной if err := tx.Where("invoice_id = ?", inv.ID).Delete(&invoices.InvoiceItem{}).Error; err != nil { return err } @@ -50,3 +53,13 @@ func (r *pgRepository) SaveInvoices(list []invoices.Invoice) error { return nil }) } + +func (r *pgRepository) CountRecent(serverID uuid.UUID, days int) (int64, error) { + var count int64 + dateFrom := time.Now().AddDate(0, 0, -days) + + err := r.db.Model(&invoices.Invoice{}). + Where("rms_server_id = ? AND date_incoming >= ?", serverID, dateFrom). + Count(&count).Error + return count, err +} diff --git a/internal/infrastructure/repository/ocr/postgres.go b/internal/infrastructure/repository/ocr/postgres.go index 5c76d8c..72824c1 100644 --- a/internal/infrastructure/repository/ocr/postgres.go +++ b/internal/infrastructure/repository/ocr/postgres.go @@ -21,9 +21,11 @@ func NewRepository(db *gorm.DB) ocr.Repository { return &pgRepository{db: db} } -func (r *pgRepository) SaveMatch(rawName string, productID uuid.UUID, quantity decimal.Decimal, containerID *uuid.UUID) error { +func (r *pgRepository) SaveMatch(serverID uuid.UUID, rawName string, productID uuid.UUID, quantity decimal.Decimal, containerID *uuid.UUID) error { normalized := strings.ToLower(strings.TrimSpace(rawName)) + match := ocr.ProductMatch{ + RMSServerID: serverID, RawName: normalized, ProductID: productID, Quantity: quantity, @@ -31,31 +33,40 @@ func (r *pgRepository) SaveMatch(rawName string, productID uuid.UUID, quantity d } return r.db.Transaction(func(tx *gorm.DB) error { + // Используем OnConflict по составному индексу (raw_name, rms_server_id) + // Но GORM может потребовать названия ограничения. + // Проще сделать через Where().Assign().FirstOrCreate() или явно указать Columns если индекс есть. + // В Entity мы указали `uniqueIndex:idx_raw_server`. + if err := tx.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "raw_name"}}, + // Указываем оба поля, входящие в unique index + Columns: []clause.Column{{Name: "raw_name"}, {Name: "rms_server_id"}}, DoUpdates: clause.AssignmentColumns([]string{"product_id", "quantity", "container_id", "updated_at"}), }).Create(&match).Error; err != nil { return err } - if err := tx.Where("raw_name = ?", normalized).Delete(&ocr.UnmatchedItem{}).Error; err != nil { + // Удаляем из Unmatched для этого сервера + if err := tx.Where("rms_server_id = ? AND raw_name = ?", serverID, normalized).Delete(&ocr.UnmatchedItem{}).Error; err != nil { return err } return nil }) } -func (r *pgRepository) DeleteMatch(rawName string) error { +func (r *pgRepository) DeleteMatch(serverID uuid.UUID, rawName string) error { normalized := strings.ToLower(strings.TrimSpace(rawName)) - return r.db.Where("raw_name = ?", normalized).Delete(&ocr.ProductMatch{}).Error + return r.db.Where("rms_server_id = ? AND raw_name = ?", serverID, normalized).Delete(&ocr.ProductMatch{}).Error } -func (r *pgRepository) FindMatch(rawName string) (*ocr.ProductMatch, error) { +func (r *pgRepository) FindMatch(serverID uuid.UUID, rawName string) (*ocr.ProductMatch, error) { normalized := strings.ToLower(strings.TrimSpace(rawName)) var match ocr.ProductMatch - // Preload Container на случай, если нам сразу нужна инфа - err := r.db.Preload("Container").Where("raw_name = ?", normalized).First(&match).Error + err := r.db.Preload("Container"). + Where("rms_server_id = ? AND raw_name = ?", serverID, normalized). + First(&match).Error + if err != nil { if err == gorm.ErrRecordNotFound { return nil, nil @@ -65,35 +76,33 @@ func (r *pgRepository) FindMatch(rawName string) (*ocr.ProductMatch, error) { return &match, nil } -func (r *pgRepository) GetAllMatches() ([]ocr.ProductMatch, error) { +func (r *pgRepository) GetAllMatches(serverID uuid.UUID) ([]ocr.ProductMatch, error) { var matches []ocr.ProductMatch - // Подгружаем Товар, Единицу и Фасовку err := r.db. Preload("Product"). Preload("Product.MainUnit"). Preload("Container"). + Where("rms_server_id = ?", serverID). Order("updated_at DESC"). Find(&matches).Error return matches, err } -// UpsertUnmatched увеличивает счетчик встречаемости -func (r *pgRepository) UpsertUnmatched(rawName string) error { +func (r *pgRepository) UpsertUnmatched(serverID uuid.UUID, rawName string) error { normalized := strings.ToLower(strings.TrimSpace(rawName)) if normalized == "" { return nil } - // Используем сырой SQL или GORM upsert expression - // PostgreSQL: INSERT ... ON CONFLICT DO UPDATE SET count = count + 1 item := ocr.UnmatchedItem{ - RawName: normalized, - Count: 1, - LastSeen: time.Now(), + RMSServerID: serverID, + RawName: normalized, + Count: 1, + LastSeen: time.Now(), } return r.db.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "raw_name"}}, + Columns: []clause.Column{{Name: "raw_name"}, {Name: "rms_server_id"}}, DoUpdates: clause.Assignments(map[string]interface{}{ "count": gorm.Expr("unmatched_items.count + 1"), "last_seen": time.Now(), @@ -101,13 +110,16 @@ func (r *pgRepository) UpsertUnmatched(rawName string) error { }).Create(&item).Error } -func (r *pgRepository) GetTopUnmatched(limit int) ([]ocr.UnmatchedItem, error) { +func (r *pgRepository) GetTopUnmatched(serverID uuid.UUID, limit int) ([]ocr.UnmatchedItem, error) { var items []ocr.UnmatchedItem - err := r.db.Order("count DESC, last_seen DESC").Limit(limit).Find(&items).Error + err := r.db.Where("rms_server_id = ?", serverID). + Order("count DESC, last_seen DESC"). + Limit(limit). + Find(&items).Error return items, err } -func (r *pgRepository) DeleteUnmatched(rawName string) error { +func (r *pgRepository) DeleteUnmatched(serverID uuid.UUID, rawName string) error { normalized := strings.ToLower(strings.TrimSpace(rawName)) - return r.db.Where("raw_name = ?", normalized).Delete(&ocr.UnmatchedItem{}).Error + return r.db.Where("rms_server_id = ? AND raw_name = ?", serverID, normalized).Delete(&ocr.UnmatchedItem{}).Error } diff --git a/internal/infrastructure/repository/operations/postgres.go b/internal/infrastructure/repository/operations/postgres.go index ce8c6c7..ace0cda 100644 --- a/internal/infrastructure/repository/operations/postgres.go +++ b/internal/infrastructure/repository/operations/postgres.go @@ -5,6 +5,8 @@ import ( "rmser/internal/domain/operations" + "github.com/google/uuid" + "gorm.io/gorm" ) @@ -16,19 +18,15 @@ func NewRepository(db *gorm.DB) operations.Repository { return &pgRepository{db: db} } -func (r *pgRepository) SaveOperations(ops []operations.StoreOperation, opType operations.OperationType, dateFrom, dateTo time.Time) error { +func (r *pgRepository) SaveOperations(ops []operations.StoreOperation, serverID uuid.UUID, opType operations.OperationType, dateFrom, dateTo time.Time) error { return r.db.Transaction(func(tx *gorm.DB) error { - // 1. Удаляем старые записи этого типа, которые пересекаются с периодом. - // Так как отчет агрегированный, мы привязываемся к периоду "с" и "по". - // Упрощение: удаляем всё, где PeriodFrom совпадает с текущей выгрузкой, - // предполагая, что мы всегда грузим одними и теми же квантами (например, месяц или неделя). - // Для надежности удалим всё, что попадает в диапазон. - if err := tx.Where("op_type = ? AND period_from >= ? AND period_to <= ?", opType, dateFrom, dateTo). + // Удаляем старые записи этого типа, но ТОЛЬКО для конкретного сервера + if err := tx.Where("rms_server_id = ? AND op_type = ? AND period_from >= ? AND period_to <= ?", + serverID, opType, dateFrom, dateTo). Delete(&operations.StoreOperation{}).Error; err != nil { return err } - // 2. Вставляем новые if len(ops) > 0 { if err := tx.CreateInBatches(ops, 500).Error; err != nil { return err diff --git a/internal/infrastructure/repository/recipes/postgres.go b/internal/infrastructure/repository/recipes/postgres.go index 0fe6177..34a7ea8 100644 --- a/internal/infrastructure/repository/recipes/postgres.go +++ b/internal/infrastructure/repository/recipes/postgres.go @@ -15,6 +15,7 @@ func NewRepository(db *gorm.DB) recipes.Repository { return &pgRepository{db: db} } +// Техкарты сохраняются пачкой, serverID внутри структуры func (r *pgRepository) SaveRecipes(list []recipes.Recipe) error { return r.db.Transaction(func(tx *gorm.DB) error { for _, recipe := range list { diff --git a/internal/infrastructure/repository/suppliers/postgres.go b/internal/infrastructure/repository/suppliers/postgres.go new file mode 100644 index 0000000..99263b6 --- /dev/null +++ b/internal/infrastructure/repository/suppliers/postgres.go @@ -0,0 +1,60 @@ +package suppliers + +import ( + "rmser/internal/domain/suppliers" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type pgRepository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) suppliers.Repository { + return &pgRepository{db: db} +} + +func (r *pgRepository) SaveBatch(list []suppliers.Supplier) error { + if len(list) == 0 { + return nil + } + return r.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, + UpdateAll: true, + }).CreateInBatches(list, 100).Error +} + +// GetRankedByUsage возвращает поставщиков для конкретного сервера, +// отсортированных по количеству накладных за последние N дней. +func (r *pgRepository) GetRankedByUsage(serverID uuid.UUID, daysLookBack int) ([]suppliers.Supplier, error) { + var result []suppliers.Supplier + + dateThreshold := time.Now().AddDate(0, 0, -daysLookBack) + + // SQL: Join Suppliers с Invoices, Group By Supplier, Order By Count DESC + // Учитываем только активных поставщиков и накладные этого же сервера (через supplier_id + default_store_id косвенно, + // но лучше явно фильтровать suppliers по rms_server_id). + // *Примечание:* Invoices пока не имеют поля rms_server_id явно в старой схеме, + // но мы должны фильтровать Suppliers по serverID. + + err := r.db.Table("suppliers"). + Select("suppliers.*, COUNT(invoices.id) as usage_count"). + Joins("LEFT JOIN invoices ON invoices.supplier_id = suppliers.id AND invoices.date_incoming >= ?", dateThreshold). + Where("suppliers.rms_server_id = ? AND suppliers.is_deleted = ?", serverID, false). + Group("suppliers.id"). + Order("usage_count DESC, suppliers.name ASC"). + Find(&result).Error + + return result, err +} + +func (r *pgRepository) Count(serverID uuid.UUID) (int64, error) { + var count int64 + err := r.db.Model(&suppliers.Supplier{}). + Where("rms_server_id = ? AND is_deleted = ?", serverID, false). + Count(&count).Error + return count, err +} diff --git a/internal/infrastructure/rms/client.go b/internal/infrastructure/rms/client.go index 49bf73c..8aa2072 100644 --- a/internal/infrastructure/rms/client.go +++ b/internal/infrastructure/rms/client.go @@ -20,6 +20,7 @@ import ( "rmser/internal/domain/catalog" "rmser/internal/domain/invoices" "rmser/internal/domain/recipes" + "rmser/internal/domain/suppliers" "rmser/pkg/logger" ) @@ -33,6 +34,7 @@ type ClientI interface { Logout() error FetchCatalog() ([]catalog.Product, error) FetchStores() ([]catalog.Store, error) + FetchSuppliers() ([]suppliers.Supplier, error) FetchMeasureUnits() ([]catalog.MeasureUnit, error) FetchRecipes(dateFrom, dateTo time.Time) ([]recipes.Recipe, error) FetchInvoices(from, to time.Time) ([]invoices.Invoice, error) @@ -755,3 +757,39 @@ func (c *Client) UpdateProduct(product ProductFullDTO) (*ProductFullDTO, error) return result.Response, nil } + +// FetchSuppliers загружает список поставщиков через XML API +func (c *Client) FetchSuppliers() ([]suppliers.Supplier, error) { + // Endpoint /resto/api/suppliers + resp, err := c.doRequest("GET", "/resto/api/suppliers", nil) + if err != nil { + return nil, fmt.Errorf("get suppliers error: %w", err) + } + defer resp.Body.Close() + + var xmlData SuppliersListXML + if err := xml.NewDecoder(resp.Body).Decode(&xmlData); err != nil { + return nil, fmt.Errorf("xml decode suppliers error: %w", err) + } + + var result []suppliers.Supplier + for _, emp := range xmlData.Employees { + id, err := uuid.Parse(emp.ID) + if err != nil { + continue + } + + isDeleted := emp.Deleted == "true" + + result = append(result, suppliers.Supplier{ + ID: id, + Name: emp.Name, + Code: emp.Code, + INN: emp.TaxpayerIdNumber, + IsDeleted: isDeleted, + // RMSServerID проставляется в сервисе перед сохранением + }) + } + + return result, nil +} diff --git a/internal/infrastructure/rms/dto.go b/internal/infrastructure/rms/dto.go index 7945abd..89162ff 100644 --- a/internal/infrastructure/rms/dto.go +++ b/internal/infrastructure/rms/dto.go @@ -243,3 +243,18 @@ type ErrorDTO struct { Code string `json:"code"` Value string `json:"value"` } + +// --- Suppliers XML (Legacy API /resto/api/suppliers) --- + +type SuppliersListXML struct { + XMLName xml.Name `xml:"employees"` + Employees []SupplierXML `xml:"employee"` +} + +type SupplierXML struct { + ID string `xml:"id"` + Name string `xml:"name"` + Code string `xml:"code"` + TaxpayerIdNumber string `xml:"taxpayerIdNumber"` // ИНН + Deleted string `xml:"deleted"` // "true" / "false" +} diff --git a/internal/infrastructure/rms/factory.go b/internal/infrastructure/rms/factory.go new file mode 100644 index 0000000..0abe293 --- /dev/null +++ b/internal/infrastructure/rms/factory.go @@ -0,0 +1,115 @@ +package rms + +import ( + "fmt" + "sync" + + "github.com/google/uuid" + "go.uber.org/zap" + + "rmser/internal/domain/account" + "rmser/pkg/crypto" + "rmser/pkg/logger" +) + +// Factory управляет созданием и переиспользованием клиентов RMS +type Factory struct { + accountRepo account.Repository + cryptoManager *crypto.CryptoManager + + // Кэш активных клиентов: ServerID -> *Client + mu sync.RWMutex + clients map[uuid.UUID]*Client +} + +func NewFactory(repo account.Repository, cm *crypto.CryptoManager) *Factory { + return &Factory{ + accountRepo: repo, + cryptoManager: cm, + clients: make(map[uuid.UUID]*Client), + } +} + +// GetClientByServerID возвращает готовый клиент для конкретного сервера +func (f *Factory) GetClientByServerID(serverID uuid.UUID) (ClientI, error) { + // 1. Пытаемся найти в кэше (быстрый путь) + f.mu.RLock() + client, exists := f.clients[serverID] + f.mu.RUnlock() + + if exists { + return client, nil + } + + // 2. Если нет в кэше - ищем в БД (медленный путь) + // Здесь нам нужен метод GetServerByID, но в репо есть только GetAll/GetActive. + // Для MVP загрузим все сервера юзера и найдем нужный, либо (лучше) добавим метод в репо позже. + // ПОКА: предполагаем, что factory используется в контексте User, поэтому лучше метод GetClientForUser + return nil, fmt.Errorf("client not found in cache (use GetClientForUser or implement GetServerByID)") +} + +// GetClientForUser находит активный сервер пользователя и возвращает клиент +func (f *Factory) GetClientForUser(userID uuid.UUID) (ClientI, error) { + // 1. Получаем настройки активного сервера из БД + server, err := f.accountRepo.GetActiveServer(userID) + if err != nil { + return nil, fmt.Errorf("db error: %w", err) + } + if server == nil { + return nil, fmt.Errorf("у пользователя нет активного сервера RMS") + } + + // 2. Проверяем кэш по ID сервера + f.mu.RLock() + cachedClient, exists := f.clients[server.ID] + f.mu.RUnlock() + + if exists { + return cachedClient, nil + } + + // 3. Создаем новый клиент под блокировкой (защита от гонки) + f.mu.Lock() + defer f.mu.Unlock() + + // Double check + if cachedClient, exists := f.clients[server.ID]; exists { + return cachedClient, nil + } + + // Расшифровка пароля + plainPass, err := f.cryptoManager.Decrypt(server.EncryptedPassword) + if err != nil { + return nil, fmt.Errorf("ошибка расшифровки пароля RMS: %w", err) + } + + // Создание клиента + newClient := NewClient(server.BaseURL, server.Login, plainPass) + + // Можно сразу проверить авторизацию (опционально, но полезно для fail-fast) + // if err := newClient.Auth(); err != nil { ... } + // Но лучше лениво, чтобы не тормозить старт. + + f.clients[server.ID] = newClient + + // Запускаем очистку старых клиентов из мапы? Пока нет, iiko токены живут не вечно, + // но структура Client легкая. Можно добавить TTL позже. + + logger.Log.Info("RMS Factory: Client created and cached", + zap.String("server_name", server.Name), + zap.String("user_id", userID.String())) + + return newClient, nil +} + +// CreateClientFromRawCredentials создает клиент без сохранения в кэш (для тестов подключения) +func (f *Factory) CreateClientFromRawCredentials(url, login, password string) *Client { + return NewClient(url, login, password) +} + +// ClearCache сбрасывает кэш для сервера (например, при смене пароля) +func (f *Factory) ClearCache(serverID uuid.UUID) { + f.mu.Lock() + defer f.mu.Unlock() + delete(f.clients, serverID) +} diff --git a/internal/services/drafts/service.go b/internal/services/drafts/service.go index 459c51d..4a06981 100644 --- a/internal/services/drafts/service.go +++ b/internal/services/drafts/service.go @@ -10,72 +10,89 @@ import ( "github.com/shopspring/decimal" "go.uber.org/zap" + "rmser/internal/domain/account" "rmser/internal/domain/catalog" "rmser/internal/domain/drafts" "rmser/internal/domain/invoices" "rmser/internal/domain/ocr" + "rmser/internal/domain/suppliers" "rmser/internal/infrastructure/rms" "rmser/pkg/logger" ) type Service struct { - draftRepo drafts.Repository - ocrRepo ocr.Repository - catalogRepo catalog.Repository - rmsClient rms.ClientI + draftRepo drafts.Repository + ocrRepo ocr.Repository + catalogRepo catalog.Repository + accountRepo account.Repository + supplierRepo suppliers.Repository + rmsFactory *rms.Factory } func NewService( draftRepo drafts.Repository, ocrRepo ocr.Repository, catalogRepo catalog.Repository, - rmsClient rms.ClientI, + accountRepo account.Repository, + supplierRepo suppliers.Repository, + rmsFactory *rms.Factory, ) *Service { return &Service{ - draftRepo: draftRepo, - ocrRepo: ocrRepo, - catalogRepo: catalogRepo, - rmsClient: rmsClient, + draftRepo: draftRepo, + ocrRepo: ocrRepo, + catalogRepo: catalogRepo, + accountRepo: accountRepo, + supplierRepo: supplierRepo, + rmsFactory: rmsFactory, } } -// GetDraft возвращает черновик с позициями -func (s *Service) GetDraft(id uuid.UUID) (*drafts.DraftInvoice, error) { - return s.draftRepo.GetByID(id) +func (s *Service) GetDraft(draftID, userID uuid.UUID) (*drafts.DraftInvoice, error) { + // TODO: Проверить что userID совпадает с draft.UserID + return s.draftRepo.GetByID(draftID) +} + +func (s *Service) GetActiveDrafts(userID uuid.UUID) ([]drafts.DraftInvoice, error) { + return s.draftRepo.GetActive(userID) +} + +// GetDictionaries возвращает Склады и Поставщиков для пользователя +func (s *Service) GetDictionaries(userID uuid.UUID) (map[string]interface{}, error) { + server, err := s.accountRepo.GetActiveServer(userID) + if err != nil || server == nil { + return nil, fmt.Errorf("active server not found") + } + + stores, _ := s.catalogRepo.GetActiveStores(server.ID) + + // Ранжированные поставщики (топ за 90 дней) + suppliersList, _ := s.supplierRepo.GetRankedByUsage(server.ID, 90) + + return map[string]interface{}{ + "stores": stores, + "suppliers": suppliersList, + }, nil } -// DeleteDraft реализует логику "Отмена -> Удаление" func (s *Service) DeleteDraft(id uuid.UUID) (string, error) { + // Без изменений логики, только вызов репо draft, err := s.draftRepo.GetByID(id) if err != nil { return "", err } - - // Сценарий 2: Если уже ОТМЕНЕН -> УДАЛЯЕМ (Soft Delete статусом) if draft.Status == drafts.StatusCanceled { draft.Status = drafts.StatusDeleted - if err := s.draftRepo.Update(draft); err != nil { - return "", err - } - logger.Log.Info("Черновик удален (скрыт)", zap.String("id", id.String())) + s.draftRepo.Update(draft) return drafts.StatusDeleted, nil } - - // Сценарий 1: Если активен -> ОТМЕНЯЕМ - // Разрешаем отменять только незавершенные - if draft.Status != drafts.StatusCompleted && draft.Status != drafts.StatusDeleted { + if draft.Status != drafts.StatusCompleted { draft.Status = drafts.StatusCanceled - if err := s.draftRepo.Update(draft); err != nil { - return "", err - } - logger.Log.Info("Черновик перемещен в отмененные", zap.String("id", id.String())) + s.draftRepo.Update(draft) return drafts.StatusCanceled, nil } - return draft.Status, nil } -// UpdateDraftHeader обновляет шапку (дата, поставщик, склад, комментарий) func (s *Service) UpdateDraftHeader(id uuid.UUID, storeID *uuid.UUID, supplierID *uuid.UUID, date time.Time, comment string) error { draft, err := s.draftRepo.GetByID(id) if err != nil { @@ -84,65 +101,46 @@ func (s *Service) UpdateDraftHeader(id uuid.UUID, storeID *uuid.UUID, supplierID if draft.Status == drafts.StatusCompleted { return errors.New("черновик уже отправлен") } - draft.StoreID = storeID draft.SupplierID = supplierID draft.DateIncoming = &date draft.Comment = comment - return s.draftRepo.Update(draft) } -// UpdateItem обновляет позицию с авто-восстановлением статуса черновика func (s *Service) UpdateItem(draftID, itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error { - // 1. Проверяем статус черновика для реализации Auto-Restore draft, err := s.draftRepo.GetByID(draftID) if err != nil { return err } - - // Если черновик был в корзине (CANCELED), возвращаем его в работу if draft.Status == drafts.StatusCanceled { draft.Status = drafts.StatusReadyToVerify - if err := s.draftRepo.Update(draft); err != nil { - logger.Log.Error("Не удалось восстановить статус черновика при редактировании", zap.Error(err)) - // Не прерываем выполнение, пробуем обновить строку - } else { - logger.Log.Info("Черновик автоматически восстановлен из отмененных", zap.String("id", draftID.String())) - } + s.draftRepo.Update(draft) } - - // 2. Обновляем саму строку (существующий вызов репозитория) return s.draftRepo.UpdateItem(itemID, productID, containerID, qty, price) } -// CommitDraft отправляет накладную в RMS -func (s *Service) CommitDraft(id uuid.UUID) (string, error) { - // 1. Загружаем актуальное состояние черновика - draft, err := s.draftRepo.GetByID(id) +// CommitDraft отправляет накладную +func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) { + // 1. Клиент для пользователя + client, err := s.rmsFactory.GetClientForUser(userID) if err != nil { return "", err } + // 2. Черновик + draft, err := s.draftRepo.GetByID(draftID) + if err != nil { + return "", err + } if draft.Status == drafts.StatusCompleted { return "", errors.New("накладная уже отправлена") } - // Валидация - if draft.StoreID == nil || *draft.StoreID == uuid.Nil { - return "", errors.New("не выбран склад") - } - if draft.SupplierID == nil || *draft.SupplierID == uuid.Nil { - return "", errors.New("не выбран поставщик") - } - if draft.DateIncoming == nil { - return "", errors.New("не выбрана дата") - } - - // Сборка Invoice для отправки + // 3. Сборка Invoice inv := invoices.Invoice{ - ID: uuid.Nil, // iiko создаст новый - DocumentNumber: draft.DocumentNumber, // Может быть пустой, iiko присвоит + ID: uuid.Nil, + DocumentNumber: draft.DocumentNumber, DateIncoming: *draft.DateIncoming, SupplierID: *draft.SupplierID, DefaultStoreID: *draft.StoreID, @@ -152,11 +150,10 @@ func (s *Service) CommitDraft(id uuid.UUID) (string, error) { for _, dItem := range draft.Items { if dItem.ProductID == nil { - // Пропускаем нераспознанные или кидаем ошибку? - break + continue // Skip unrecognized } - // Расчет суммы (если не задана, считаем) + // Если суммы нет, считаем sum := dItem.Sum if sum.IsZero() { sum = dItem.Quantity.Mul(dItem.Price) @@ -169,7 +166,6 @@ func (s *Service) CommitDraft(id uuid.UUID) (string, error) { Sum: sum, ContainerID: dItem.ContainerID, } - inv.Items = append(inv.Items, invItem) } @@ -177,86 +173,64 @@ func (s *Service) CommitDraft(id uuid.UUID) (string, error) { return "", errors.New("нет распознанных позиций для отправки") } - // Отправка - docNum, err := s.rmsClient.CreateIncomingInvoice(inv) + // 4. Отправка в RMS + docNum, err := client.CreateIncomingInvoice(inv) if err != nil { return "", err } - // Обновление статуса + // 5. Обновление статуса draft.Status = drafts.StatusCompleted - // Можно сохранить docNum, если бы было поле в Draft, но у нас есть rms_invoice_id (uuid), - // а возвращается строка номера. Ок, просто меняем статус. - if err := s.draftRepo.Update(draft); err != nil { - logger.Log.Error("Failed to update draft status after commit", zap.Error(err)) - } + s.draftRepo.Update(draft) - // 4. ОБУЧЕНИЕ (Deferred Learning) - // Запускаем в горутине, чтобы не задерживать ответ пользователю - go s.learnFromDraft(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) + } return docNum, nil } -// learnFromDraft сохраняет новые связи на основе подтвержденного черновика -func (s *Service) learnFromDraft(draft *drafts.DraftInvoice) { +func (s *Service) learnFromDraft(draft *drafts.DraftInvoice, serverID uuid.UUID) { for _, item := range draft.Items { - // Учимся только если: - // 1. Есть RawName (текст из чека) - // 2. Пользователь (или OCR) выбрал ProductID if item.RawName != "" && item.ProductID != nil { - - // Если нужно запоминать коэффициент (например, всегда 1 или то, что ввел юзер), - // то берем item.Quantity. Но обычно для матчинга мы запоминаем факт связи, - // а дефолтное кол-во ставим 1. qty := decimal.NewFromFloat(1.0) - - err := s.ocrRepo.SaveMatch(item.RawName, *item.ProductID, qty, item.ContainerID) + err := s.ocrRepo.SaveMatch(serverID, item.RawName, *item.ProductID, qty, item.ContainerID) if err != nil { - logger.Log.Warn("Failed to learn match", - zap.String("raw", item.RawName), - zap.Error(err)) - } else { - logger.Log.Info("Learned match", zap.String("raw", item.RawName)) + logger.Log.Warn("Failed to learn match", zap.Error(err)) } } } } -// GetActiveStores возвращает список складов -func (s *Service) GetActiveStores() ([]catalog.Store, error) { - return s.catalogRepo.GetActiveStores() -} - -// GetActiveDrafts возвращает список черновиков в работе -func (s *Service) GetActiveDrafts() ([]drafts.DraftInvoice, error) { - return s.draftRepo.GetActive() -} - -// CreateProductContainer создает новую фасовку в iiko и сохраняет её в локальной БД -// Возвращает UUID созданной фасовки. -func (s *Service) CreateProductContainer(productID uuid.UUID, name string, count decimal.Decimal) (uuid.UUID, error) { - // 1. Получаем полную карточку товара из iiko - // Используем инфраструктурный DTO, так как нам нужна полная структура для апдейта - fullProduct, err := s.rmsClient.GetProductByID(productID) +func (s *Service) CreateProductContainer(userID uuid.UUID, productID uuid.UUID, name string, count decimal.Decimal) (uuid.UUID, error) { + client, err := s.rmsFactory.GetClientForUser(userID) if err != nil { - return uuid.Nil, fmt.Errorf("ошибка получения товара из iiko: %w", err) + return uuid.Nil, err + } + server, _ := s.accountRepo.GetActiveServer(userID) // нужен ServerID для сохранения в локальную БД + + fullProduct, err := client.GetProductByID(productID) + if err != nil { + return uuid.Nil, fmt.Errorf("error fetching product: %w", err) } - // 2. Валидация на дубликаты (по имени или коэффициенту) - // iiko разрешает дубли, но нам это не нужно. + // Валидация на дубли targetCount, _ := count.Float64() for _, c := range fullProduct.Containers { if !c.Deleted && (c.Name == name || (c.Count == targetCount)) { - // Если такая фасовка уже есть, возвращаем её ID - // (Можно добавить логику обновления имени, но пока просто вернем ID) if c.ID != nil && *c.ID != "" { return uuid.Parse(*c.ID) } } } - // 3. Вычисляем следующий num (iiko использует строки "1", "2"...) + // Next Num maxNum := 0 for _, c := range fullProduct.Containers { if n, err := strconv.Atoi(c.Num); err == nil { @@ -267,32 +241,27 @@ func (s *Service) CreateProductContainer(productID uuid.UUID, name string, count } nextNum := strconv.Itoa(maxNum + 1) - // 4. Добавляем новую фасовку в список + // Add newContainerDTO := rms.ContainerFullDTO{ - ID: nil, // Null, чтобы iiko создала новый ID + ID: nil, Num: nextNum, Name: name, Count: targetCount, UseInFront: true, Deleted: false, - // Остальные поля можно оставить 0/false по умолчанию } - fullProduct.Containers = append(fullProduct.Containers, newContainerDTO) - // 5. Отправляем обновление в iiko - updatedProduct, err := s.rmsClient.UpdateProduct(*fullProduct) + // Update RMS + updatedProduct, err := client.UpdateProduct(*fullProduct) if err != nil { - return uuid.Nil, fmt.Errorf("ошибка обновления товара в iiko: %w", err) + return uuid.Nil, fmt.Errorf("error updating product: %w", err) } - // 6. Ищем нашу созданную фасовку в ответе, чтобы получить её ID - // Ищем по уникальной комбинации Name + Count, которую мы только что отправили + // Find created ID var createdID uuid.UUID found := false - for _, c := range updatedProduct.Containers { - // Сравниваем float с небольшим эпсилоном на всякий случай, хотя JSON должен вернуть точно if c.Name == name && c.Count == targetCount && !c.Deleted { if c.ID != nil { createdID, err = uuid.Parse(*c.ID) @@ -305,28 +274,18 @@ func (s *Service) CreateProductContainer(productID uuid.UUID, name string, count } if !found { - return uuid.Nil, errors.New("фасовка отправлена, но сервер не вернул её ID (возможно, ошибка логики поиска)") + return uuid.Nil, errors.New("container created but id not found") } - // 7. Сохраняем новую фасовку в локальную БД + // Save Local newLocalContainer := catalog.ProductContainer{ - ID: createdID, - ProductID: productID, - Name: name, - Count: count, + ID: createdID, + RMSServerID: server.ID, // <-- NEW + ProductID: productID, + Name: name, + Count: count, } - - if err := s.catalogRepo.SaveContainer(newLocalContainer); err != nil { - logger.Log.Error("Ошибка сохранения новой фасовки в локальную БД", zap.Error(err)) - // Не возвращаем ошибку клиенту, так как в iiko она уже создана. - // Просто в следующем SyncCatalog она подтянется, но лучше иметь её сразу. - } - - logger.Log.Info("Создана новая фасовка", - zap.String("product_id", productID.String()), - zap.String("container_id", createdID.String()), - zap.String("name", name), - zap.String("count", count.String())) + s.catalogRepo.SaveContainer(newLocalContainer) return createdID, nil } diff --git a/internal/services/ocr/service.go b/internal/services/ocr/service.go index a712383..64cb3a7 100644 --- a/internal/services/ocr/service.go +++ b/internal/services/ocr/service.go @@ -6,58 +6,66 @@ import ( "github.com/google/uuid" "github.com/shopspring/decimal" - "go.uber.org/zap" + "rmser/internal/domain/account" "rmser/internal/domain/catalog" "rmser/internal/domain/drafts" "rmser/internal/domain/ocr" "rmser/internal/infrastructure/ocr_client" - "rmser/pkg/logger" ) type Service struct { ocrRepo ocr.Repository catalogRepo catalog.Repository draftRepo drafts.Repository - pyClient *ocr_client.Client // Клиент к Python сервису + accountRepo account.Repository // <-- NEW + pyClient *ocr_client.Client } func NewService( ocrRepo ocr.Repository, catalogRepo catalog.Repository, draftRepo drafts.Repository, + accountRepo account.Repository, // <-- NEW pyClient *ocr_client.Client, ) *Service { return &Service{ ocrRepo: ocrRepo, catalogRepo: catalogRepo, draftRepo: draftRepo, + accountRepo: accountRepo, pyClient: pyClient, } } -// ProcessReceiptImage - Создает черновик, распознает, сохраняет результаты -func (s *Service) ProcessReceiptImage(ctx context.Context, chatID int64, imgData []byte) (*drafts.DraftInvoice, error) { - // 1. Создаем заготовку черновика +// ProcessReceiptImage +func (s *Service) ProcessReceiptImage(ctx context.Context, userID uuid.UUID, imgData []byte) (*drafts.DraftInvoice, error) { + // 1. Получаем активный сервер для UserID + server, err := s.accountRepo.GetActiveServer(userID) + if err != nil || server == nil { + return nil, fmt.Errorf("no active server for user") + } + serverID := server.ID + + // 2. Создаем черновик draft := &drafts.DraftInvoice{ - ChatID: chatID, - Status: drafts.StatusProcessing, + UserID: userID, // <-- Исправлено с ChatID на UserID + RMSServerID: serverID, // <-- NEW + Status: drafts.StatusProcessing, } if err := s.draftRepo.Create(draft); err != nil { return nil, fmt.Errorf("failed to create draft: %w", err) } - logger.Log.Info("Создан черновик", zap.String("draft_id", draft.ID.String())) - // 2. Отправляем в Python OCR + // 3. Отправляем в Python OCR rawResult, err := s.pyClient.ProcessImage(ctx, imgData, "receipt.jpg") if err != nil { - // Ставим статус ошибки draft.Status = drafts.StatusError _ = s.draftRepo.Update(draft) return nil, fmt.Errorf("python ocr error: %w", err) } - // 3. Обрабатываем результаты и создаем Items + // 4. Матчинг (с учетом ServerID) var draftItems []drafts.DraftInvoiceItem for _, rawItem := range rawResult.Items { @@ -66,60 +74,33 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, chatID int64, imgData RawName: rawItem.RawName, RawAmount: decimal.NewFromFloat(rawItem.Amount), RawPrice: decimal.NewFromFloat(rawItem.Price), - // Quantity/Price по умолчанию берем как Raw, если не будет пересчета - Quantity: decimal.NewFromFloat(rawItem.Amount), - Price: decimal.NewFromFloat(rawItem.Price), - Sum: decimal.NewFromFloat(rawItem.Sum), + Quantity: decimal.NewFromFloat(rawItem.Amount), + Price: decimal.NewFromFloat(rawItem.Price), + Sum: decimal.NewFromFloat(rawItem.Sum), } - // Пытаемся найти матчинг - match, err := s.ocrRepo.FindMatch(rawItem.RawName) - if err != nil { - logger.Log.Error("db error finding match", zap.Error(err)) - } + match, _ := s.ocrRepo.FindMatch(serverID, rawItem.RawName) // <-- ServerID if match != nil { item.IsMatched = true item.ProductID = &match.ProductID item.ContainerID = match.ContainerID - } else { - // Если не нашли - сохраняем в Unmatched для статистики и подсказок - if err := s.ocrRepo.UpsertUnmatched(rawItem.RawName); err != nil { - logger.Log.Warn("failed to save unmatched", zap.Error(err)) - } + s.ocrRepo.UpsertUnmatched(serverID, rawItem.RawName) // <-- ServerID } draftItems = append(draftItems, item) } - // 4. Сохраняем позиции в БД + // 5. Сохраняем draft.Status = drafts.StatusReadyToVerify - - if err := s.draftRepo.Update(draft); err != nil { - return nil, fmt.Errorf("failed to update draft status: %w", err) - } - draft.Items = draftItems - - if err := s.draftRepo.CreateItems(draftItems); err != nil { - return nil, fmt.Errorf("failed to save items: %w", err) - } + s.draftRepo.Update(draft) + s.draftRepo.CreateItems(draftItems) return draft, nil } -// ProcessedItem - результат обработки одной строки чека -type ProcessedItem struct { - RawName string - Amount decimal.Decimal - Price decimal.Decimal - Sum decimal.Decimal - - IsMatched bool - ProductID *uuid.UUID - MatchSource string // "learned", "auto", "manual" -} - +// Добавить структуры в конец файла type ContainerForIndex struct { ID string `json:"id"` Name string `json:"name"` @@ -134,9 +115,14 @@ type ProductForIndex struct { Containers []ContainerForIndex `json:"containers"` } -// GetCatalogForIndexing - возвращает облегченный каталог -func (s *Service) GetCatalogForIndexing() ([]ProductForIndex, error) { - products, err := s.catalogRepo.GetActiveGoods() +// GetCatalogForIndexing +func (s *Service) GetCatalogForIndexing(userID uuid.UUID) ([]ProductForIndex, error) { + server, err := s.accountRepo.GetActiveServer(userID) + if err != nil || server == nil { + return nil, fmt.Errorf("no server") + } + + products, err := s.catalogRepo.GetActiveGoods(server.ID) if err != nil { return nil, err } @@ -169,37 +155,45 @@ func (s *Service) GetCatalogForIndexing() ([]ProductForIndex, error) { return result, nil } -// SaveMapping сохраняет привязку с количеством и фасовкой -func (s *Service) SaveMapping(rawName string, productID uuid.UUID, quantity decimal.Decimal, containerID *uuid.UUID) error { - return s.ocrRepo.SaveMatch(rawName, productID, quantity, containerID) -} - -// DeleteMatch удаляет ошибочную привязку -func (s *Service) DeleteMatch(rawName string) error { - return s.ocrRepo.DeleteMatch(rawName) -} - -// GetKnownMatches возвращает список всех обученных связей -func (s *Service) GetKnownMatches() ([]ocr.ProductMatch, error) { - return s.ocrRepo.GetAllMatches() -} - -// GetUnmatchedItems возвращает список частых нераспознанных строк -func (s *Service) GetUnmatchedItems() ([]ocr.UnmatchedItem, error) { - // Берем топ 50 нераспознанных - return s.ocrRepo.GetTopUnmatched(50) -} - -// FindKnownMatch ищет, знаем ли мы уже этот товар -func (s *Service) FindKnownMatch(rawName string) (*ocr.ProductMatch, error) { - return s.ocrRepo.FindMatch(rawName) -} - -// SearchProducts ищет товары в БД по части названия, коду или артикулу -func (s *Service) SearchProducts(query string) ([]catalog.Product, error) { +func (s *Service) SearchProducts(userID uuid.UUID, query string) ([]catalog.Product, error) { if len(query) < 2 { - // Слишком короткий запрос, возвращаем пустой список return []catalog.Product{}, nil } - return s.catalogRepo.Search(query) + server, err := s.accountRepo.GetActiveServer(userID) + if err != nil || server == nil { + return nil, fmt.Errorf("no server") + } + return s.catalogRepo.Search(server.ID, query) +} + +func (s *Service) SaveMapping(userID uuid.UUID, rawName string, productID uuid.UUID, quantity decimal.Decimal, containerID *uuid.UUID) error { + server, err := s.accountRepo.GetActiveServer(userID) + if err != nil || server == nil { + return fmt.Errorf("no server") + } + return s.ocrRepo.SaveMatch(server.ID, rawName, productID, quantity, containerID) +} + +func (s *Service) DeleteMatch(userID uuid.UUID, rawName string) error { + server, err := s.accountRepo.GetActiveServer(userID) + if err != nil || server == nil { + return fmt.Errorf("no server") + } + return s.ocrRepo.DeleteMatch(server.ID, rawName) +} + +func (s *Service) GetKnownMatches(userID uuid.UUID) ([]ocr.ProductMatch, error) { + server, err := s.accountRepo.GetActiveServer(userID) + if err != nil || server == nil { + return nil, fmt.Errorf("no server") + } + return s.ocrRepo.GetAllMatches(server.ID) +} + +func (s *Service) GetUnmatchedItems(userID uuid.UUID) ([]ocr.UnmatchedItem, error) { + server, err := s.accountRepo.GetActiveServer(userID) + if err != nil || server == nil { + return nil, fmt.Errorf("no server") + } + return s.ocrRepo.GetTopUnmatched(server.ID, 50) } diff --git a/internal/services/sync/service.go b/internal/services/sync/service.go index 33e0898..c480bf0 100644 --- a/internal/services/sync/service.go +++ b/internal/services/sync/service.go @@ -4,263 +4,238 @@ import ( "fmt" "time" - "go.uber.org/zap" - "github.com/google/uuid" "github.com/shopspring/decimal" + "go.uber.org/zap" + "rmser/internal/domain/account" "rmser/internal/domain/catalog" "rmser/internal/domain/invoices" "rmser/internal/domain/operations" "rmser/internal/domain/recipes" + "rmser/internal/domain/suppliers" "rmser/internal/infrastructure/rms" "rmser/pkg/logger" ) const ( - // Пресеты от пользователя PresetPurchases = "1a3297e1-cb05-55dc-98a7-c13f13bc85a7" // Закупки PresetUsage = "24d9402e-2d01-eca1-ebeb-7981f7d1cb86" // Расход ) type Service struct { - rmsClient rms.ClientI - catalogRepo catalog.Repository - recipeRepo recipes.Repository - invoiceRepo invoices.Repository - opRepo operations.Repository + rmsFactory *rms.Factory + accountRepo account.Repository + catalogRepo catalog.Repository + recipeRepo recipes.Repository + invoiceRepo invoices.Repository + opRepo operations.Repository + supplierRepo suppliers.Repository } func NewService( - rmsClient rms.ClientI, + rmsFactory *rms.Factory, + accountRepo account.Repository, catalogRepo catalog.Repository, recipeRepo recipes.Repository, invoiceRepo invoices.Repository, opRepo operations.Repository, + supplierRepo suppliers.Repository, ) *Service { return &Service{ - rmsClient: rmsClient, - catalogRepo: catalogRepo, - recipeRepo: recipeRepo, - invoiceRepo: invoiceRepo, - opRepo: opRepo, + rmsFactory: rmsFactory, + accountRepo: accountRepo, + catalogRepo: catalogRepo, + recipeRepo: recipeRepo, + invoiceRepo: invoiceRepo, + opRepo: opRepo, + supplierRepo: supplierRepo, } } -// SyncCatalog загружает номенклатуру и сохраняет в БД -func (s *Service) SyncCatalog() error { - logger.Log.Info("Начало синхронизации справочников...") +// SyncAllData запускает полную синхронизацию для конкретного пользователя +func (s *Service) SyncAllData(userID uuid.UUID) error { + logger.Log.Info("Запуск полной синхронизации", zap.String("user_id", userID.String())) - // 1. Склады (INVENTORY_ASSETS) - важно для создания накладных - if err := s.SyncStores(); err != nil { - logger.Log.Error("Ошибка синхронизации складов", zap.Error(err)) - // Не прерываем, идем дальше + // 1. Получаем клиент и инфо о сервере + client, err := s.rmsFactory.GetClientForUser(userID) + if err != nil { + return err + } + server, err := s.accountRepo.GetActiveServer(userID) + if err != nil || server == nil { + return fmt.Errorf("active server not found for user %s", userID) + } + serverID := server.ID + + // 2. Справочники + if err := s.syncStores(client, serverID); err != nil { + logger.Log.Error("Sync Stores failed", zap.Error(err)) + } + if err := s.syncMeasureUnits(client, serverID); err != nil { + logger.Log.Error("Sync Units failed", zap.Error(err)) } - // 2. Единицы измерения - if err := s.syncMeasureUnits(); err != nil { + // 3. Поставщики + if err := s.syncSuppliers(client, serverID); err != nil { + logger.Log.Error("Sync Suppliers failed", zap.Error(err)) + } + + // 4. Товары + if err := s.syncProducts(client, serverID); err != nil { + logger.Log.Error("Sync Products failed", zap.Error(err)) + } + + // 5. Техкарты (тяжелый запрос) + if err := s.syncRecipes(client, serverID); err != nil { + logger.Log.Error("Sync Recipes failed", zap.Error(err)) + } + + // 6. Накладные (история) + if err := s.syncInvoices(client, serverID); err != nil { + logger.Log.Error("Sync Invoices failed", zap.Error(err)) + } + + // 7. Складские операции (тяжелый запрос) + // Для MVP можно отключить, если долго грузится + // if err := s.SyncStoreOperations(client, serverID); err != nil { + // logger.Log.Error("Sync Operations failed", zap.Error(err)) + // } + + logger.Log.Info("Синхронизация завершена", zap.String("user_id", userID.String())) + return nil +} + +func (s *Service) syncSuppliers(c rms.ClientI, serverID uuid.UUID) error { + list, err := c.FetchSuppliers() + if err != nil { + return err + } + // Проставляем ServerID + for i := range list { + list[i].RMSServerID = serverID + } + return s.supplierRepo.SaveBatch(list) +} + +func (s *Service) syncStores(c rms.ClientI, serverID uuid.UUID) error { + stores, err := c.FetchStores() + if err != nil { + return err + } + for i := range stores { + stores[i].RMSServerID = serverID + } + return s.catalogRepo.SaveStores(stores) +} + +func (s *Service) syncMeasureUnits(c rms.ClientI, serverID uuid.UUID) error { + units, err := c.FetchMeasureUnits() + if err != nil { + return err + } + for i := range units { + units[i].RMSServerID = serverID + } + return s.catalogRepo.SaveMeasureUnits(units) +} + +func (s *Service) syncProducts(c rms.ClientI, serverID uuid.UUID) error { + products, err := c.FetchCatalog() + if err != nil { + return err + } + // Важно: Проставляем ID рекурсивно и в фасовки + for i := range products { + products[i].RMSServerID = serverID + for j := range products[i].Containers { + products[i].Containers[j].RMSServerID = serverID + } + } + return s.catalogRepo.SaveProducts(products) +} + +func (s *Service) syncRecipes(c rms.ClientI, serverID uuid.UUID) error { + dateFrom := time.Now().AddDate(0, -3, 0) // За 3 месяца + dateTo := time.Now() + recipesList, err := c.FetchRecipes(dateFrom, dateTo) + if err != nil { return err } - // 3. Товары - logger.Log.Info("Запрос товаров из RMS...") - products, err := s.rmsClient.FetchCatalog() - if err != nil { - return fmt.Errorf("ошибка получения каталога из RMS: %w", err) + for i := range recipesList { + recipesList[i].RMSServerID = serverID + for j := range recipesList[i].Items { + recipesList[i].Items[j].RMSServerID = serverID + } } - - if err := s.catalogRepo.SaveProducts(products); err != nil { - return fmt.Errorf("ошибка сохранения продуктов в БД: %w", err) - } - - logger.Log.Info("Синхронизация номенклатуры завершена", zap.Int("count", len(products))) - return nil + return s.recipeRepo.SaveRecipes(recipesList) } -func (s *Service) syncMeasureUnits() error { - logger.Log.Info("Синхронизация единиц измерения...") - units, err := s.rmsClient.FetchMeasureUnits() +func (s *Service) syncInvoices(c rms.ClientI, serverID uuid.UUID) error { + lastDate, err := s.invoiceRepo.GetLastInvoiceDate(serverID) if err != nil { - return fmt.Errorf("ошибка получения ед.изм: %w", err) - } - if err := s.catalogRepo.SaveMeasureUnits(units); err != nil { - return fmt.Errorf("ошибка сохранения ед.изм: %w", err) - } - logger.Log.Info("Единицы измерения обновлены", zap.Int("count", len(units))) - return nil -} - -// SyncRecipes загружает техкарты за указанный период (или за последние 30 дней по умолчанию) -func (s *Service) SyncRecipes() error { - logger.Log.Info("Начало синхронизации техкарт") - - // RMS требует dateFrom. Берем широкий диапазон, например, с начала года или фиксированную дату, - // либо можно сделать конфигурируемым. Для примера берем -3 месяца от текущей даты. - // В реальном проде лучше брать дату последнего изменения, если API поддерживает revision, - // но V2 API iiko часто требует полной перезагрузки актуальных карт. - dateFrom := time.Now().AddDate(0, -3, 0) - dateTo := time.Now() // +1 месяц вперед на случай будущих меню - - recipes, err := s.rmsClient.FetchRecipes(dateFrom, dateTo) - if err != nil { - return fmt.Errorf("ошибка получения техкарт из RMS: %w", err) - } - - if err := s.recipeRepo.SaveRecipes(recipes); err != nil { - return fmt.Errorf("ошибка сохранения техкарт в БД: %w", err) - } - - logger.Log.Info("Синхронизация техкарт завершена", zap.Int("count", len(recipes))) - return nil -} - -// SyncInvoices загружает накладные. Если в базе пусто, грузит за последние N дней. -func (s *Service) SyncInvoices() error { - logger.Log.Info("Начало синхронизации накладных") - - lastDate, err := s.invoiceRepo.GetLastInvoiceDate() - if err != nil { - return fmt.Errorf("ошибка получения даты последней накладной: %w", err) + return err } var from time.Time to := time.Now() if lastDate != nil { - // Берем следующий день после последней загрузки или тот же день, чтобы обновить изменения from = *lastDate } else { - // Дефолтная загрузка за 30 дней назад - from = time.Now().AddDate(0, 0, -30) + from = time.Now().AddDate(0, 0, -45) // 45 дней по дефолту } - logger.Log.Info("Запрос накладных", zap.Time("from", from), zap.Time("to", to)) - - invoices, err := s.rmsClient.FetchInvoices(from, to) + invs, err := c.FetchInvoices(from, to) if err != nil { - return fmt.Errorf("ошибка получения накладных из RMS: %w", err) + return err } - if len(invoices) == 0 { - logger.Log.Info("Новых накладных не найдено") - return nil + for i := range invs { + invs[i].RMSServerID = serverID + // В Items пока не добавляли ServerID } - if err := s.invoiceRepo.SaveInvoices(invoices); err != nil { - return fmt.Errorf("ошибка сохранения накладных в БД: %w", err) + if len(invs) > 0 { + return s.invoiceRepo.SaveInvoices(invs) } - - logger.Log.Info("Синхронизация накладных завершена", zap.Int("count", len(invoices))) return nil } -// classifyOperation определяет тип операции на основе DocumentType -func classifyOperation(docType string) operations.OperationType { - switch docType { - // === ПРИХОД (PURCHASE) === - case "INCOMING_INVOICE": // Приходная накладная - return operations.OpTypePurchase - case "INCOMING_SERVICE": // Акт приема услуг (редко товары, но бывает) - return operations.OpTypePurchase - - // === РАСХОД (USAGE) === - case "SALES_DOCUMENT": // Акт реализации (продажа) - return operations.OpTypeUsage - case "WRITEOFF_DOCUMENT": // Акт списания (порча, проработки) - return operations.OpTypeUsage - case "OUTGOING_INVOICE": // Расходная накладная - return operations.OpTypeUsage - case "SESSION_ACCEPTANCE": // Принятие смены (иногда агрегирует продажи) - return operations.OpTypeUsage - case "DISASSEMBLE_DOCUMENT": // Акт разбора (расход целого) - return operations.OpTypeUsage - - // === Спорные/Игнорируемые === - // RETURNED_INVOICE (Возвратная накладная) - технически это уменьшение прихода, - // но для рекомендаций "что мы покупаем" лучше обрабатывать отдельно или как минус-purchase. - // Пока отнесем к UNKNOWN, чтобы не портить статистику чистого прихода, - // либо можно считать как Purchase с отрицательным Amount (если XML дает минус). - case "RETURNED_INVOICE": - return operations.OpTypeUnknown - - case "INTERNAL_TRANSFER": - return operations.OpTypeUnknown // Перемещение нас не интересует в рамках рекомендаций "купил/продал" - case "INCOMING_INVENTORY": - return operations.OpTypeUnknown // Инвентаризация - - default: - return operations.OpTypeUnknown - } -} - -// SyncStores загружает список складов -func (s *Service) SyncStores() error { - logger.Log.Info("Синхронизация складов...") - stores, err := s.rmsClient.FetchStores() - if err != nil { - return fmt.Errorf("ошибка получения складов из RMS: %w", err) - } - - if err := s.catalogRepo.SaveStores(stores); err != nil { - return fmt.Errorf("ошибка сохранения складов в БД: %w", err) - } - - logger.Log.Info("Склады обновлены", zap.Int("count", len(stores))) - return nil -} - -func (s *Service) SyncStoreOperations() error { +// SyncStoreOperations публичный, если нужно вызывать отдельно +func (s *Service) SyncStoreOperations(c rms.ClientI, serverID uuid.UUID) error { dateTo := time.Now() dateFrom := dateTo.AddDate(0, 0, -30) - // 1. Синхронизируем Закупки (PresetPurchases) - // Мы передаем OpTypePurchase, чтобы репозиторий знал, какую "полку" очистить перед записью. - if err := s.syncReport(PresetPurchases, operations.OpTypePurchase, dateFrom, dateTo); err != nil { - return fmt.Errorf("ошибка синхронизации закупок: %w", err) + if err := s.syncReport(c, serverID, PresetPurchases, operations.OpTypePurchase, dateFrom, dateTo); err != nil { + return fmt.Errorf("purchases sync error: %w", err) } - - // 2. Синхронизируем Расход (PresetUsage) - if err := s.syncReport(PresetUsage, operations.OpTypeUsage, dateFrom, dateTo); err != nil { - return fmt.Errorf("ошибка синхронизации расхода: %w", err) + if err := s.syncReport(c, serverID, PresetUsage, operations.OpTypeUsage, dateFrom, dateTo); err != nil { + return fmt.Errorf("usage sync error: %w", err) } - return nil } -func (s *Service) syncReport(presetID string, targetOpType operations.OperationType, from, to time.Time) error { - logger.Log.Info("Запрос отчета RMS", zap.String("preset", presetID)) - - items, err := s.rmsClient.FetchStoreOperations(presetID, from, to) +func (s *Service) syncReport(c rms.ClientI, serverID uuid.UUID, presetID string, targetOpType operations.OperationType, from, to time.Time) error { + items, err := c.FetchStoreOperations(presetID, from, to) if err != nil { return err } var ops []operations.StoreOperation for _, item := range items { - // 1. Валидация товара pID, err := uuid.Parse(item.ProductID) if err != nil { continue } - - // 2. Определение реального типа операции realOpType := classifyOperation(item.DocumentType) - - // 3. Фильтрация "мусора" - // Если мы грузим отчет "Закупки", но туда попало "Перемещение" (из-за кривого пресета), - // мы это пропустим. Либо если документ неизвестного типа. - if realOpType == operations.OpTypeUnknown { - continue - } - - // Важно: Мы сохраняем только то, что соответствует целевому типу этапа синхронизации. - // Если в пресете "Закупки" попалась "Реализация", мы не должны писать её в "Закупки", - // и не должны писать в "Расход" (так как мы сейчас чистим "Закупки"). - if realOpType != targetOpType { + if realOpType == operations.OpTypeUnknown || realOpType != targetOpType { continue } ops = append(ops, operations.StoreOperation{ + RMSServerID: serverID, ProductID: pID, OpType: realOpType, DocumentType: item.DocumentType, @@ -274,13 +249,59 @@ func (s *Service) syncReport(presetID string, targetOpType operations.OperationT }) } - if err := s.opRepo.SaveOperations(ops, targetOpType, from, to); err != nil { - return err + return s.opRepo.SaveOperations(ops, serverID, targetOpType, from, to) +} + +func classifyOperation(docType string) operations.OperationType { + switch docType { + case "INCOMING_INVOICE", "INCOMING_SERVICE": + return operations.OpTypePurchase + case "SALES_DOCUMENT", "WRITEOFF_DOCUMENT", "OUTGOING_INVOICE", "SESSION_ACCEPTANCE", "DISASSEMBLE_DOCUMENT": + return operations.OpTypeUsage + default: + return operations.OpTypeUnknown + } +} + +// Добавляем структуру для возврата статистики +type SyncStats struct { + ServerName string + ProductsCount int64 + StoresCount int64 + SuppliersCount int64 + InvoicesLast30 int64 + LastInvoice *time.Time +} + +// GetSyncStats собирает информацию о данных текущего сервера +func (s *Service) GetSyncStats(userID uuid.UUID) (*SyncStats, error) { + server, err := s.accountRepo.GetActiveServer(userID) + if err != nil || server == nil { + return nil, fmt.Errorf("нет активного сервера") } - logger.Log.Info("Отчет сохранен", - zap.String("op_type", string(targetOpType)), - zap.Int("received", len(items)), - zap.Int("saved", len(ops))) - return nil + stats := &SyncStats{ + ServerName: server.Name, + } + + // Параллельный запуск не обязателен, запросы Count очень быстрые + if cnt, err := s.catalogRepo.CountGoods(server.ID); err == nil { + stats.ProductsCount = cnt + } + + if cnt, err := s.catalogRepo.CountStores(server.ID); err == nil { + stats.StoresCount = cnt + } + + if cnt, err := s.supplierRepo.Count(server.ID); err == nil { + stats.SuppliersCount = cnt + } + + if cnt, err := s.invoiceRepo.CountRecent(server.ID, 30); err == nil { + stats.InvoicesLast30 = cnt + } + + stats.LastInvoice, _ = s.invoiceRepo.GetLastInvoiceDate(server.ID) + + return stats, nil } diff --git a/internal/transport/http/handlers/drafts.go b/internal/transport/http/handlers/drafts.go index 799bfb0..3cde6b1 100644 --- a/internal/transport/http/handlers/drafts.go +++ b/internal/transport/http/handlers/drafts.go @@ -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(), - }) -} \ No newline at end of file + c.JSON(http.StatusOK, gin.H{"status": newStatus, "id": id.String()}) +} diff --git a/internal/transport/http/handlers/ocr.go b/internal/transport/http/handlers/ocr.go index cf4fb63..d1dc86d 100644 --- a/internal/transport/http/handlers/ocr.go +++ b/internal/transport/http/handlers/ocr.go @@ -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 } diff --git a/internal/transport/http/middleware/auth.go b/internal/transport/http/middleware/auth.go new file mode 100644 index 0000000..a215b26 --- /dev/null +++ b/internal/transport/http/middleware/auth.go @@ -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() + } +} diff --git a/internal/transport/telegram/bot.go b/internal/transport/telegram/bot.go index 943593a..feb2bfd 100644 --- a/internal/transport/telegram/bot.go +++ b/internal/transport/telegram/bot.go @@ -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 := "👋 Панель управления RMSER\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_" + 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("🖥 Ваши серверы (%d):\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("⚠️ Статус: Ошибка (%v)", err) + } else { + lastUpdate := "—" + if stats.LastInvoice != nil { + lastUpdate = stats.LastInvoice.Format("02.01.2006") + } + + txt = fmt.Sprintf("🔄 Состояние справочников\n\n"+ + "🏢 Сервер: %s\n"+ + "📦 Товары: %d\n"+ + "🚚 Поставщики: %d\n"+ + "🏭 Склады: %d\n\n"+ + "📄 Накладные (30дн): %d\n"+ + "📅 Посл. документ: %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 := "💰 Ваш баланс\n\n" + + "💵 Текущий счет: 0.00 ₽\n" + + "💎 Тариф: Free\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("🔗 Введите URL вашего сервера iikoRMS.\nПример: https://iiko.myrest.ru:443\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("👤 Введите логин пользователя iiko:") + + case StateAddServerLogin: + bot.fsm.UpdateContext(userID, func(ctx *UserContext) { + ctx.TempLogin = text + ctx.State = StateAddServerPassword + }) + return c.Send("🔑 Введите пароль:") + + 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("✅ Сервер добавлен и выбран активным!", 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("✅ Успех! Все позиции (%d) распознаны.\n\nПереходите к созданию накладной.", len(draft.Items)) } else { - msgText = fmt.Sprintf("⚠️ Внимание! Распознано %d из %d позиций.\n\nНекоторые товары требуют ручного сопоставления. Нажмите кнопку ниже, чтобы исправить.", matchedCount, len(draft.Items)) + msgText = fmt.Sprintf("⚠️ Внимание! Распознано %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 +} diff --git a/internal/transport/telegram/fsm.go b/internal/transport/telegram/fsm.go new file mode 100644 index 0000000..66cd1c7 --- /dev/null +++ b/internal/transport/telegram/fsm.go @@ -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) +} diff --git a/pkg/crypto/crypto.go b/pkg/crypto/crypto.go new file mode 100644 index 0000000..f050587 --- /dev/null +++ b/pkg/crypto/crypto.go @@ -0,0 +1,80 @@ +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" // <-- Добавлен импорт + "encoding/base64" + "errors" + "io" +) + +// CryptoManager занимается шифрованием чувствительных данных (паролей RMS) +type CryptoManager struct { + key []byte +} + +func NewCryptoManager(secretKey string) *CryptoManager { + // Исправление: + // AES требует строго 16, 24 или 32 байта. + // Чтобы не заставлять пользователя считать символы в конфиге, + // мы хешируем любую строку в SHA-256, получая всегда валидные 32 байта. + hasher := sha256.New() + hasher.Write([]byte(secretKey)) + keyBytes := hasher.Sum(nil) + + return &CryptoManager{key: keyBytes} +} + +// Encrypt шифрует строку и возвращает base64 +func (m *CryptoManager) Encrypt(plaintext string) (string, error) { + block, err := aes.NewCipher(m.key) + if err != nil { + return "", err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + + ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// Decrypt расшифровывает base64 строку +func (m *CryptoManager) Decrypt(ciphertextBase64 string) (string, error) { + data, err := base64.StdEncoding.DecodeString(ciphertextBase64) + if err != nil { + return "", err + } + + block, err := aes.NewCipher(m.key) + if err != nil { + return "", err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonceSize := gcm.NonceSize() + if len(data) < nonceSize { + return "", errors.New("ciphertext too short") + } + + nonce, ciphertext := data[:nonceSize], data[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", err + } + + return string(plaintext), nil +} diff --git a/rmser-view/src/App.tsx b/rmser-view/src/App.tsx index b810a5a..d82a153 100644 --- a/rmser-view/src/App.tsx +++ b/rmser-view/src/App.tsx @@ -1,24 +1,57 @@ +import { useEffect, useState } from 'react'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { Result, Button } from 'antd'; import { Providers } from './components/layout/Providers'; import { AppLayout } from './components/layout/AppLayout'; -import { Dashboard } from './pages/Dashboard'; import { OcrLearning } from './pages/OcrLearning'; import { InvoiceDraftPage } from './pages/InvoiceDraftPage'; import { DraftsList } from './pages/DraftsList'; +import { UNAUTHORIZED_EVENT } from './services/api'; + +// Компонент заглушки для 401 ошибки +const UnauthorizedScreen = () => ( +
+ + Перейти в бота @RmserBot + + } + /> +
+); function App() { + const [isUnauthorized, setIsUnauthorized] = useState(false); + + useEffect(() => { + const handleUnauthorized = () => setIsUnauthorized(true); + + // Подписываемся на событие из api.ts + window.addEventListener(UNAUTHORIZED_EVENT, handleUnauthorized); + + return () => { + window.removeEventListener(UNAUTHORIZED_EVENT, handleUnauthorized); + }; + }, []); + + if (isUnauthorized) { + return ; + } + return ( }> - } /> + {/* Если Dashboard удален, можно сделать редирект на invoices */} + } /> + } /> - - {/* Список черновиков */} } /> - - {/* Редактирование черновика */} } /> } /> diff --git a/rmser-view/src/pages/InvoiceDraftPage.tsx b/rmser-view/src/pages/InvoiceDraftPage.tsx index d2d5af2..feee0c7 100644 --- a/rmser-view/src/pages/InvoiceDraftPage.tsx +++ b/rmser-view/src/pages/InvoiceDraftPage.tsx @@ -24,8 +24,14 @@ export const InvoiceDraftPage: React.FC = () => { const [updatingItems, setUpdatingItems] = useState>(new Set()); // --- ЗАПРОСЫ --- - const storesQuery = useQuery({ queryKey: ['stores'], queryFn: api.getStores }); - const suppliersQuery = useQuery({ queryKey: ['suppliers'], queryFn: api.getSuppliers }); + + // Получаем сразу все справочники одним запросом + const dictQuery = useQuery({ + queryKey: ['dictionaries'], + queryFn: api.getDictionaries, + staleTime: 1000 * 60 * 5 // Кэшируем на 5 минут + }); + const recommendationsQuery = useQuery({ queryKey: ['recommendations'], queryFn: api.getRecommendations }); const draftQuery = useQuery({ @@ -39,8 +45,9 @@ export const InvoiceDraftPage: React.FC = () => { }); const draft = draftQuery.data; + const stores = dictQuery.data?.stores || []; + const suppliers = dictQuery.data?.suppliers || []; - // ... (МУТАЦИИ оставляем без изменений) ... const updateItemMutation = useMutation({ mutationFn: (vars: { itemId: string; payload: UpdateDraftItemRequest }) => api.updateDraftItem(id!, vars.itemId, vars.payload), @@ -115,11 +122,14 @@ export const InvoiceDraftPage: React.FC = () => { const handleCommit = async () => { try { + // Валидируем форму (включая нового поставщика) const values = await form.validateFields(); + if (invalidItemsCount > 0) { message.warning(`Осталось ${invalidItemsCount} нераспознанных товаров!`); return; } + commitMutation.mutate({ date_incoming: values.date_incoming.format('YYYY-MM-DD'), store_id: values.store_id, @@ -127,7 +137,7 @@ export const InvoiceDraftPage: React.FC = () => { comment: values.comment || '', }); } catch { - message.error('Заполните обязательные поля'); + message.error('Заполните обязательные поля (Склад, Поставщик)'); } }; @@ -162,12 +172,11 @@ export const InvoiceDraftPage: React.FC = () => { return (
- {/* Header: Уплотненный, без переноса слов */} + {/* Header */}
- {/* Form: Compact margins */} + {/* Form: Склады и Поставщики */}
@@ -199,22 +208,27 @@ export const InvoiceDraftPage: React.FC = () => { - + ({ label: s.name, value: s.id }))} + loading={dictQuery.isLoading} + options={suppliers.map(s => ({ label: s.name, value: s.id }))} size="middle" + showSearch + filterOption={(input, option) => + (option?.label ?? '').toLowerCase().includes(input.toLowerCase()) + } /> @@ -243,14 +257,14 @@ export const InvoiceDraftPage: React.FC = () => {
{/* Footer Actions */} - +
Итого: diff --git a/rmser-view/src/services/api.ts b/rmser-view/src/services/api.ts index 071ded3..54f380d 100644 --- a/rmser-view/src/services/api.ts +++ b/rmser-view/src/services/api.ts @@ -13,15 +13,25 @@ import type { DraftInvoice, UpdateDraftItemRequest, CommitDraftRequest, - // Новые типы ProductSearchResult, AddContainerRequest, - AddContainerResponse + AddContainerResponse, + DictionariesResponse, + DraftSummary } from './types'; // Базовый URL const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api'; +// Телеграм объект +const tg = window.Telegram?.WebApp; + +// ID для локальной разработки (Fallback) +const DEBUG_USER_ID = 665599275; + +// Событие для глобальной обработки 401 +export const UNAUTHORIZED_EVENT = 'rms_unauthorized'; + const apiClient = axios.create({ baseURL: API_URL, headers: { @@ -29,33 +39,36 @@ const apiClient = axios.create({ }, }); +// --- Request Interceptor (Авторизация) --- +apiClient.interceptors.request.use((config) => { + // 1. Пробуем взять ID из Telegram WebApp + // 2. Ищем в URL параметрах (удобно для тестов в браузере: ?_tg_id=123) + // 3. Используем хардкод для локальной разработки + const urlParams = new URLSearchParams(window.location.search); + const paramId = urlParams.get('_tg_id'); + + const userId = tg?.initDataUnsafe?.user?.id || paramId || DEBUG_USER_ID; + + if (userId) { + config.headers['X-Telegram-User-ID'] = userId.toString(); + } + + return config; +}); + +// --- Response Interceptor (Обработка ошибок) --- apiClient.interceptors.response.use( (response) => response, (error) => { + if (error.response && error.response.status === 401) { + // Генерируем кастомное событие, которое поймает App.tsx + window.dispatchEvent(new Event(UNAUTHORIZED_EVENT)); + } console.error('API Error:', error); return Promise.reject(error); } ); -// Мок поставщиков -const MOCK_SUPPLIERS: Supplier[] = [ - { id: '00000000-0000-0000-0000-000000000001', name: 'ООО "Рога и Копыта"' }, - { id: '00000000-0000-0000-0000-000000000002', name: 'ИП Иванов (Овощи)' }, - { id: '00000000-0000-0000-0000-000000000003', name: 'Metro Cash&Carry' }, - { id: '00000000-0000-0000-0000-000000000004', name: 'Simple Wine' }, -]; - -// интерфейс для списка (краткий) -export interface DraftSummary { - id: string; - document_number: string; - date_incoming: string; - status: string; - items_count: number; - total_sum: number; - store_name?: string; -} - export const api = { checkHealth: async (): Promise => { const { data } = await apiClient.get('/health'); @@ -67,14 +80,11 @@ export const api = { return data; }, - // Оставляем для совместимости со старыми компонентами (если используются), - // но в Draft Flow будем использовать поиск. getCatalogItems: async (): Promise => { const { data } = await apiClient.get('/ocr/catalog'); return data; }, - // Поиск товаров --- searchProducts: async (query: string): Promise => { const { data } = await apiClient.get('/ocr/search', { params: { q: query } @@ -82,9 +92,7 @@ export const api = { return data; }, - // Создание фасовки --- createContainer: async (payload: AddContainerRequest): Promise => { - // Внимание: URL эндпоинта взят из вашего ТЗ (/drafts/container) const { data } = await apiClient.post('/drafts/container', payload); return data; }, @@ -109,15 +117,28 @@ export const api = { return data; }, - getStores: async (): Promise => { - const { data } = await apiClient.get('/dictionaries/stores'); + // --- НОВЫЙ МЕТОД: Получение всех справочников --- + getDictionaries: async (): Promise => { + const { data } = await apiClient.get('/dictionaries'); return data; }, + // Старые методы оставляем для совместимости, но они могут вызывать getDictionaries внутри или deprecated endpoint + getStores: async (): Promise => { + // Можно использовать новый эндпоинт и возвращать часть данных + const { data } = await apiClient.get('/dictionaries'); + return data.stores; + }, + getSuppliers: async (): Promise => { - return new Promise((resolve) => { - setTimeout(() => resolve(MOCK_SUPPLIERS), 300); - }); + // Реальный запрос вместо мока + const { data } = await apiClient.get('/dictionaries'); + return data.suppliers; + }, + + getDrafts: async (): Promise => { + const { data } = await apiClient.get('/drafts'); + return data; }, getDraft: async (id: string): Promise => { @@ -125,12 +146,6 @@ export const api = { return data; }, - // Получить список черновиков - getDrafts: async (): Promise => { - const { data } = await apiClient.get('/drafts'); - return data; - }, - updateDraftItem: async (draftId: string, itemId: string, payload: UpdateDraftItemRequest): Promise => { const { data } = await apiClient.patch(`/drafts/${draftId}/items/${itemId}`, payload); return data; @@ -140,8 +155,7 @@ export const api = { const { data } = await apiClient.post<{ document_number: string }>(`/drafts/${draftId}/commit`, payload); return data; }, - - // Отменить/Удалить черновик + deleteDraft: async (id: string): Promise => { await apiClient.delete(`/drafts/${id}`); }, diff --git a/rmser-view/src/services/types.ts b/rmser-view/src/services/types.ts index 9adc4c4..5f791ac 100644 --- a/rmser-view/src/services/types.ts +++ b/rmser-view/src/services/types.ts @@ -121,6 +121,11 @@ export interface Supplier { name: string; } +export interface DictionariesResponse { + stores: Store[]; + suppliers: Supplier[]; +} + // --- Черновик Накладной (Draft) --- export type DraftStatus = 'PROCESSING' | 'READY_TO_VERIFY' | 'COMPLETED' | 'ERROR' | 'CANCELED'; @@ -146,6 +151,18 @@ export interface DraftItem { container?: ProductContainer; // Развернутый объект для UI } +// --- Список Черновиков (Summary) --- +export interface DraftSummary { + id: UUID; + document_number: string; + date_incoming: string; + status: DraftStatus; // Используем существующий тип статуса + items_count: number; + total_sum: number; + store_name?: string; + created_at: string; +} + export interface DraftInvoice { id: UUID; status: DraftStatus; diff --git a/rmser-view/src/vite-env.d.ts b/rmser-view/src/vite-env.d.ts new file mode 100644 index 0000000..45d0dd5 --- /dev/null +++ b/rmser-view/src/vite-env.d.ts @@ -0,0 +1,24 @@ +/// + +interface TelegramWebApp { + initData: string; + initDataUnsafe: { + user?: { + id: number; + first_name: string; + last_name?: string; + username?: string; + language_code?: string; + }; + // ... другие поля по необходимости + }; + close: () => void; + expand: () => void; + // ... другие методы +} + +interface Window { + Telegram?: { + WebApp: TelegramWebApp; + }; +} \ No newline at end of file