From 8332b6ecda5df10b15b98e0af10c06657b84a61b Mon Sep 17 00:00:00 2001 From: SERTY Date: Tue, 27 Jan 2026 00:17:31 +0300 Subject: [PATCH] 1 --- go_backend_dump.py | 184 --------------------------------------------- 1 file changed, 184 deletions(-) delete mode 100644 go_backend_dump.py diff --git a/go_backend_dump.py b/go_backend_dump.py deleted file mode 100644 index 95a1cac..0000000 --- a/go_backend_dump.py +++ /dev/null @@ -1,184 +0,0 @@ -# -*- coding: utf-8 -*- -# Этот файл сгенерирован автоматически. -# Содержит дерево проекта и файлы (.go, .json, .mod, .md) в экранированном виде. - -project_tree = ''' -. -├── .env -├── Dockerfile -├── README.md -├── cmd -│ └── main.go -├── config -│ └── config.go -├── config.yaml -├── docker-compose.yml -├── go.mod -├── go.sum -├── internal -│ ├── domain -│ │ ├── account -│ │ │ └── entity.go -│ │ ├── billing -│ │ │ └── entity.go -│ │ ├── catalog -│ │ │ ├── entity.go -│ │ │ └── store.go -│ │ ├── drafts -│ │ │ └── entity.go -│ │ ├── interfaces.go -│ │ ├── invoices -│ │ │ └── entity.go -│ │ ├── ocr -│ │ │ └── entity.go -│ │ ├── operations -│ │ │ └── entity.go -│ │ ├── photos -│ │ │ └── entity.go -│ │ ├── recipes -│ │ │ └── entity.go -│ │ ├── recommendations -│ │ │ └── entity.go -│ │ └── suppliers -│ │ └── entity.go -│ ├── infrastructure -│ │ ├── db -│ │ │ └── postgres.go -│ │ ├── ocr_client -│ │ │ ├── client.go -│ │ │ └── dto.go -│ │ ├── redis -│ │ │ └── client.go -│ │ ├── repository -│ │ │ ├── account -│ │ │ │ └── postgres.go -│ │ │ ├── billing -│ │ │ │ └── postgres.go -│ │ │ ├── catalog -│ │ │ │ └── postgres.go -│ │ │ ├── drafts -│ │ │ │ └── postgres.go -│ │ │ ├── invoices -│ │ │ │ └── postgres.go -│ │ │ ├── ocr -│ │ │ │ └── postgres.go -│ │ │ ├── operations -│ │ │ │ └── postgres.go -│ │ │ ├── photos -│ │ │ │ └── postgres.go -│ │ │ ├── recipes -│ │ │ │ └── postgres.go -│ │ │ ├── recommendations -│ │ │ │ └── postgres.go -│ │ │ └── suppliers -│ │ │ └── postgres.go -│ │ ├── rms -│ │ │ ├── client.go -│ │ │ ├── dto.go -│ │ │ └── factory.go -│ │ └── yookassa -│ │ ├── client.go -│ │ └── dto.go -│ ├── services -│ │ ├── billing -│ │ │ └── service.go -│ │ ├── drafts -│ │ │ └── service.go -│ │ ├── invoices -│ │ │ └── service.go -│ │ ├── ocr -│ │ │ └── service.go -│ │ ├── photos -│ │ │ └── service.go -│ │ ├── recommend -│ │ │ └── service.go -│ │ └── sync -│ │ └── service.go -│ └── transport -│ ├── http -│ │ ├── handlers -│ │ │ ├── billing.go -│ │ │ ├── drafts.go -│ │ │ ├── invoices.go -│ │ │ ├── ocr.go -│ │ │ ├── photos.go -│ │ │ ├── recommendations.go -│ │ │ └── settings.go -│ │ └── middleware -│ │ └── auth.go -│ └── telegram -│ ├── bot.go -│ └── fsm.go -└── pkg - ├── crypto - │ └── crypto.go - └── logger - └── logger.go -''' - -project_files = { - "README.md": "**RMSer** — это SaaS-платформа, которая избавляет бухгалтеров и менеджеров ресторанов от рутинного ввода накладных. С помощью оптического распознавания (OCR) и алгоритмов сопоставления данных, сервис сокращает время приемки товара в несколько раз.\n\n## ✨ Ключевые преимущества\n\n### 🧠 Умный матчинг и самообучение\nИнтерфейс сопоставления позволяет один раз связать текст из чека поставщика («Томат черри 250г имп») с вашей позицией в iiko («Томаты Черри»). Система сохраняет эту связь и при следующих загрузках подставляет нужный товар автоматически.\n\n### ⚙️ Тонкая настройка под объект\n* **Склад по умолчанию:** Автоматическая подстановка нужного склада для каждой новой накладной.\n* **Корневая группа поиска:** Ограничьте область поиска товаров (например, только категория «Продукты»). Это исключает попадание в накладные услуг, инвентаря или блюд.\n* **Авто-проведение:** Возможность настроить автоматическое создание накладной в статусе «Проведено» (Processed).\n* **Добавление фасовок:** Для каждого товара из iiko можно добавить фасовку и текст из чека будет сопоставлен с ней.\n\n### 👥 Управление командой в приложении\nПолноценный интерфейс управления пользователями внутри Telegram Mini App:\n* **Owner:** Полный доступ и управление подпиской.\n* **Admin:** Настройка интеграции и сопоставление товаров.\n* **Operator:** Режим «только фото» — идеально для линейного персонала на приемке.\n* **Инвайт-система:** Быстрое добавление сотрудников через ссылку.\n\n## 🛠 Стек технологий\n\n* **Language:** Go 1.25\n* **Framework:** Gin Gonic (REST API)\n* **Database:** PostgreSQL 16 (GORM)\n* **Cache:** Redis\n* **Bot Engine:** Telebot v3\n* **Containerization:** Docker & Docker Compose\n* **Security:** AES-256 (GCM) для шифрования учетных данных RMS.\n\n## 💳 Биллинг и Бонусы\nСервис работает по модели Pay-as-you-go (пакеты накладных) или по подписке. \n**Welcome Bonus:** При подключении нового сервера система автоматически начисляет **10 накладных на 30 дней** для бесплатного тестирования.", - "cmd/main.go": "package main\n\nimport (\n\t\"log\"\n\t\"os\"\n\t\"time\"\n\n\t\"github.com/gin-contrib/cors\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/google/uuid\"\n\t\"go.uber.org/zap\"\n\n\t\"rmser/config\"\n\t\"rmser/internal/infrastructure/db\"\n\t\"rmser/internal/infrastructure/ocr_client\"\n\t\"rmser/internal/infrastructure/yookassa\"\n\n\t\"rmser/internal/transport/http/middleware\"\n\ttgBot \"rmser/internal/transport/telegram\"\n\n\t// Repositories\n\taccountPkg \"rmser/internal/infrastructure/repository/account\"\n\tbillingPkg \"rmser/internal/infrastructure/repository/billing\"\n\tcatalogPkg \"rmser/internal/infrastructure/repository/catalog\"\n\tdraftsPkg \"rmser/internal/infrastructure/repository/drafts\"\n\tinvoicesPkg \"rmser/internal/infrastructure/repository/invoices\"\n\tocrRepoPkg \"rmser/internal/infrastructure/repository/ocr\"\n\topsRepoPkg \"rmser/internal/infrastructure/repository/operations\"\n\tphotosPkg \"rmser/internal/infrastructure/repository/photos\"\n\trecipesPkg \"rmser/internal/infrastructure/repository/recipes\"\n\trecRepoPkg \"rmser/internal/infrastructure/repository/recommendations\"\n\tsuppliersPkg \"rmser/internal/infrastructure/repository/suppliers\"\n\n\t\"rmser/internal/infrastructure/rms\"\n\n\t// Services\n\tbillingServicePkg \"rmser/internal/services/billing\"\n\tdraftsServicePkg \"rmser/internal/services/drafts\"\n\tinvoicesServicePkg \"rmser/internal/services/invoices\"\n\tocrServicePkg \"rmser/internal/services/ocr\"\n\tphotosServicePkg \"rmser/internal/services/photos\"\n\trecServicePkg \"rmser/internal/services/recommend\"\n\t\"rmser/internal/services/sync\"\n\n\t// Handlers\n\t\"rmser/internal/transport/http/handlers\"\n\n\t\"rmser/pkg/crypto\"\n\t\"rmser/pkg/logger\"\n)\n\nfunc main() {\n\t// 1. Config\n\tcfg, err := config.LoadConfig(\".\")\n\tif err != nil {\n\t\tlog.Fatalf(\"Ошибка загрузки конфига: %v\", err)\n\t}\n\n\t// 2. Logger\n\tlogger.Init(cfg.App.Mode)\n\tdefer logger.Log.Sync()\n\n\tif err := os.MkdirAll(cfg.App.StoragePath, 0755); err != nil {\n\t\tlogger.Log.Fatal(\"Не удалось создать директорию для загрузок\", zap.Error(err), zap.String(\"path\", cfg.App.StoragePath))\n\t}\n\tlogger.Log.Info(\"Запуск приложения rmser\", zap.String(\"mode\", cfg.App.Mode))\n\n\t// 3. Crypto & DB\n\tif cfg.Security.SecretKey == \"\" {\n\t\tlogger.Log.Fatal(\"Security.SecretKey не задан в конфиге!\")\n\t}\n\tcryptoManager := crypto.NewCryptoManager(cfg.Security.SecretKey)\n\tdatabase := db.NewPostgresDB(cfg.DB.DSN)\n\n\t// 4. Repositories\n\taccountRepo := accountPkg.NewRepository(database)\n\tbillingRepo := billingPkg.NewRepository(database)\n\tcatalogRepo := catalogPkg.NewRepository(database)\n\trecipesRepo := recipesPkg.NewRepository(database)\n\tinvoicesRepo := invoicesPkg.NewRepository(database)\n\topsRepo := opsRepoPkg.NewRepository(database)\n\trecRepo := recRepoPkg.NewRepository(database)\n\tocrRepo := ocrRepoPkg.NewRepository(database)\n\tphotosRepo := photosPkg.NewRepository(database)\n\tdraftsRepo := draftsPkg.NewRepository(database)\n\tsupplierRepo := suppliersPkg.NewRepository(database)\n\n\t// 5. RMS Factory\n\trmsFactory := rms.NewFactory(accountRepo, cryptoManager)\n\n\t// 6. Services\n\tpyClient := ocr_client.NewClient(cfg.OCR.ServiceURL)\n\tykClient := yookassa.NewClient(cfg.YooKassa.ShopID, cfg.YooKassa.SecretKey)\n\tbillingService := billingServicePkg.NewService(billingRepo, accountRepo, ykClient)\n\n\tsyncService := sync.NewService(rmsFactory, accountRepo, catalogRepo, recipesRepo, invoicesRepo, opsRepo, supplierRepo)\n\trecService := recServicePkg.NewService(recRepo)\n\tocrService := ocrServicePkg.NewService(ocrRepo, catalogRepo, draftsRepo, accountRepo, photosRepo, pyClient, cfg.App.StoragePath)\n\t// Устанавливаем DevIDs для OCR сервиса\n\tocrService.SetDevIDs(cfg.App.DevIDs)\n\tdraftsService := draftsServicePkg.NewService(draftsRepo, ocrRepo, catalogRepo, accountRepo, supplierRepo, photosRepo, invoicesRepo, rmsFactory, billingService)\n\tinvoicesService := invoicesServicePkg.NewService(invoicesRepo, draftsRepo, supplierRepo, rmsFactory)\n\tphotosService := photosServicePkg.NewService(photosRepo, draftsRepo, accountRepo)\n\n\t// 7. Handlers\n\tdraftsHandler := handlers.NewDraftsHandler(draftsService)\n\tbillingHandler := handlers.NewBillingHandler(billingService)\n\tocrHandler := handlers.NewOCRHandler(ocrService)\n\tphotosHandler := handlers.NewPhotosHandler(photosService)\n\trecommendHandler := handlers.NewRecommendationsHandler(recService)\n\tsettingsHandler := handlers.NewSettingsHandler(accountRepo, catalogRepo)\n\tinvoicesHandler := handlers.NewInvoiceHandler(invoicesService, syncService)\n\n\t// 8. Telegram Bot (Передаем syncService)\n\tif cfg.Telegram.Token != \"\" {\n\t\tbot, err := tgBot.NewBot(cfg.Telegram, ocrService, syncService, billingService, accountRepo, rmsFactory, cryptoManager, cfg.App.MaintenanceMode, cfg.App.DevIDs)\n\t\tif err != nil {\n\t\t\tlogger.Log.Fatal(\"Ошибка создания Telegram бота\", zap.Error(err))\n\t\t}\n\t\tbillingService.SetNotifier(bot)\n\t\tsettingsHandler.SetNotifier(bot)\n\t\t// Устанавливаем нотификатор для OCR сервиса\n\t\tocrService.SetNotifier(bot)\n\t\tgo bot.Start()\n\t\tdefer bot.Stop()\n\t}\n\n\t// 9. HTTP Server\n\tif cfg.App.Mode == \"release\" {\n\t\tgin.SetMode(gin.ReleaseMode)\n\t}\n\tr := gin.Default()\n\n\tr.POST(\"/api/webhooks/yookassa\", billingHandler.YooKassaWebhook)\n\n\tcorsConfig := cors.DefaultConfig()\n\tcorsConfig.AllowAllOrigins = true\n\tcorsConfig.AllowMethods = []string{\"GET\", \"POST\", \"PUT\", \"PATCH\", \"DELETE\", \"OPTIONS\"}\n\tcorsConfig.AllowHeaders = []string{\"Origin\", \"Content-Length\", \"Content-Type\", \"Authorization\", \"X-Telegram-User-ID\"}\n\tr.Use(cors.New(corsConfig))\n\n\t// --- STATIC FILES SERVING ---\n\t// Раздаем папку uploads по урлу /api/uploads\n\tr.Static(\"/api/uploads\", cfg.App.StoragePath)\n\n\tapi := r.Group(\"/api\")\n\n\tapi.Use(middleware.AuthMiddleware(accountRepo, cfg.Telegram.Token, cfg.App.MaintenanceMode, cfg.App.DevIDs))\n\t{\n\t\t// Drafts & Invoices\n\t\tapi.GET(\"/drafts\", draftsHandler.GetDrafts)\n\t\tapi.GET(\"/drafts/:id\", draftsHandler.GetDraft)\n\t\tapi.DELETE(\"/drafts/:id\", draftsHandler.DeleteDraft)\n\t\t// Items CRUD\n\t\tapi.POST(\"/drafts/:id/items\", draftsHandler.AddDraftItem)\n\t\tapi.DELETE(\"/drafts/:id/items/:itemId\", draftsHandler.DeleteDraftItem)\n\t\tapi.PATCH(\"/drafts/:id/items/:itemId\", draftsHandler.UpdateItem)\n\t\tapi.POST(\"/drafts/:id/commit\", draftsHandler.CommitDraft)\n\t\tapi.POST(\"/drafts/container\", draftsHandler.AddContainer)\n\n\t\t// Settings\n\t\tapi.GET(\"/settings\", settingsHandler.GetSettings)\n\t\tapi.POST(\"/settings\", settingsHandler.UpdateSettings)\n\t\t// Photos Storage\n\t\tapi.GET(\"/photos\", photosHandler.GetPhotos)\n\t\tapi.DELETE(\"/photos/:id\", photosHandler.DeletePhoto)\n\t\t// User Management\n\t\tapi.GET(\"/settings/users\", settingsHandler.GetServerUsers)\n\t\tapi.PATCH(\"/settings/users/:userId\", settingsHandler.UpdateUserRole)\n\t\tapi.DELETE(\"/settings/users/:userId\", settingsHandler.RemoveUser)\n\n\t\t// Dictionaries\n\t\tapi.GET(\"/dictionaries\", draftsHandler.GetDictionaries)\n\t\tapi.GET(\"/dictionaries/groups\", settingsHandler.GetGroupsTree)\n\t\tapi.GET(\"/dictionaries/stores\", draftsHandler.GetStores)\n\n\t\t// Recommendations\n\t\tapi.GET(\"/recommendations\", recommendHandler.GetRecommendations)\n\n\t\t// OCR & Matching\n\t\tapi.GET(\"/ocr/catalog\", ocrHandler.GetCatalog)\n\t\tapi.GET(\"/ocr/matches\", ocrHandler.GetMatches)\n\t\tapi.POST(\"/ocr/match\", ocrHandler.SaveMatch)\n\t\tapi.DELETE(\"/ocr/match\", ocrHandler.DeleteMatch)\n\t\tapi.GET(\"/ocr/unmatched\", ocrHandler.GetUnmatched)\n\t\tapi.DELETE(\"/ocr/unmatched\", ocrHandler.DiscardUnmatched)\n\t\tapi.GET(\"/ocr/search\", ocrHandler.SearchProducts)\n\n\t\t// Invoices\n\t\tapi.GET(\"/invoices/:id\", invoicesHandler.GetInvoice)\n\t\tapi.POST(\"/invoices/sync\", invoicesHandler.SyncInvoices)\n\n\t\t// Manual Sync Trigger\n\t\tapi.POST(\"/sync/all\", func(c *gin.Context) {\n\t\t\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\t\t\tforce := c.Query(\"force\") == \"true\"\n\t\t\t// Запускаем в горутине, чтобы не держать соединение\n\t\t\tgo func() {\n\t\t\t\tif err := syncService.SyncAllData(userID, force); err != nil {\n\t\t\t\t\tlogger.Log.Error(\"Manual sync failed\", zap.Error(err))\n\t\t\t\t}\n\t\t\t}()\n\t\t\tc.JSON(200, gin.H{\"status\": \"sync_started\", \"message\": \"Синхронизация запущена в фоне\"})\n\t\t})\n\t}\n\n\tr.GET(\"/health\", func(c *gin.Context) {\n\t\tc.JSON(200, gin.H{\"status\": \"ok\", \"time\": time.Now().Format(time.RFC3339)})\n\t})\n\n\tlogger.Log.Info(\"Сервер запускается\", zap.String(\"port\", cfg.App.Port))\n\tif err := r.Run(\":\" + cfg.App.Port); err != nil {\n\t\tlogger.Log.Fatal(\"Ошибка запуска сервера\", zap.Error(err))\n\t}\n}\n", - "config.yaml": "app:\n port: \"8080\"\n mode: \"debug\" # debug выводит цветные логи\n drop_tables: false\n storage_path: \"./uploads\"\n public_url: \"https://rmser.serty.top\"\n maintenance_mode: true\n dev_ids: [665599275] # Укажите здесь ваш ID и ID тестировщиков\n\ndb:\n dsn: \"host=postgres user=rmser password=mhrcadmin994525 dbname=rmser_db port=5432 sslmode=disable TimeZone=Europe/Moscow\"\n\nredis:\n addr: \"localhost:6379\"\n password: \"\"\n db: 0\n\nrms:\n base_url: \"https://rest-mesto-vstrechi.iiko.it\" # Например http://95.12.34.56:8080\n login: \"MH\"\n password: \"MhLevfqkexit632597\" # Пароль в открытом виде (приложение само хеширует)\n\nocr:\n service_url: \"http://ocr-service:5005\"\n\nsecurity:\n secret_key: \"mhrcadmin994525\"\n\ntelegram:\n token: \"7858843765:AAE5HBQHbef4fGLoMDV91bhHZQFcnsBDcv4\"\n admin_ids: [665599275]\n web_app_url: \"https://rmser.serty.top\"\n\nyookassa:\n shop_id: \"1234397\"\n secret_key: \"live_bRlT9tJRi1hvP7_-C6xjdmzNHpaz9rIs9G0gzv6OPA0\"", - "config/config.go": "package config\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/spf13/viper\"\n)\n\ntype Config struct {\n\tApp AppConfig\n\tDB DBConfig\n\tRedis RedisConfig\n\tRMS RMSConfig\n\tOCR OCRConfig\n\tTelegram TelegramConfig\n\tSecurity SecurityConfig\n\tYooKassa YooKassaConfig `mapstructure:\"yookassa\"`\n}\n\ntype AppConfig struct {\n\tPort string `mapstructure:\"port\"`\n\tMode string `mapstructure:\"mode\"` // debug/release\n\tDropTables bool `mapstructure:\"drop_tables\"`\n\tStoragePath string `mapstructure:\"storage_path\"`\n\tPublicURL string `mapstructure:\"public_url\"`\n\n\tMaintenanceMode bool `mapstructure:\"maintenance_mode\"`\n DevIDs []int64 `mapstructure:\"dev_ids\"` // Whitelist для режима разработки\n}\n\ntype DBConfig struct {\n\tDSN string `mapstructure:\"dsn\"`\n}\n\ntype RedisConfig struct {\n\tAddr string `mapstructure:\"addr\"`\n\tPassword string `mapstructure:\"password\"`\n\tDB int `mapstructure:\"db\"`\n}\ntype RMSConfig struct {\n\tBaseURL string `mapstructure:\"base_url\"`\n\tLogin string `mapstructure:\"login\"`\n\tPassword string `mapstructure:\"password\"` // Исходный пароль, хеширование будет в клиенте\n}\n\ntype OCRConfig struct {\n\tServiceURL string `mapstructure:\"service_url\"`\n}\n\ntype TelegramConfig struct {\n\tToken string `mapstructure:\"token\"`\n\tAdminIDs []int64 `mapstructure:\"admin_ids\"`\n\tWebAppURL string `mapstructure:\"web_app_url\"`\n}\n\ntype SecurityConfig struct {\n\tSecretKey string `mapstructure:\"secret_key\"` // 32 bytes for AES-256\n}\n\ntype YooKassaConfig struct {\n\tShopID string `mapstructure:\"shop_id\"`\n\tSecretKey string `mapstructure:\"secret_key\"`\n}\n\n// LoadConfig загружает конфигурацию из файла и переменных окружения\nfunc LoadConfig(path string) (*Config, error) {\n\tviper.AddConfigPath(path)\n\tviper.SetConfigName(\"config\")\n\tviper.SetConfigType(\"yaml\")\n\n\tviper.AutomaticEnv()\n\tviper.SetEnvKeyReplacer(strings.NewReplacer(\".\", \"_\"))\n\n\tif err := viper.ReadInConfig(); err != nil {\n\t\treturn nil, fmt.Errorf(\"ошибка чтения конфига: %w\", err)\n\t}\n\n\tvar cfg Config\n\tif err := viper.Unmarshal(&cfg); err != nil {\n\t\treturn nil, fmt.Errorf(\"ошибка анмаршалинга конфига: %w\", err)\n\t}\n\n\treturn &cfg, nil\n}\n", - "go.mod": "module rmser\n\ngo 1.25.5\n\nrequire (\n\tgithub.com/gin-contrib/cors v1.7.6\n\tgithub.com/gin-gonic/gin v1.11.0\n\tgithub.com/google/uuid v1.6.0\n\tgithub.com/jackc/pgx/v5 v5.6.0\n\tgithub.com/redis/go-redis/v9 v9.17.1\n\tgithub.com/shopspring/decimal v1.4.0\n\tgithub.com/spf13/viper v1.21.0\n\tgo.uber.org/zap v1.27.1\n\tgopkg.in/telebot.v3 v3.3.8\n\tgorm.io/driver/postgres v1.6.0\n\tgorm.io/gorm v1.31.1\n)\n\nrequire (\n\tgithub.com/bytedance/sonic v1.14.0 // indirect\n\tgithub.com/bytedance/sonic/loader v0.3.0 // indirect\n\tgithub.com/cespare/xxhash/v2 v2.3.0 // indirect\n\tgithub.com/cloudwego/base64x v0.1.6 // indirect\n\tgithub.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect\n\tgithub.com/fsnotify/fsnotify v1.9.0 // indirect\n\tgithub.com/gabriel-vasile/mimetype v1.4.9 // indirect\n\tgithub.com/gin-contrib/sse v1.1.0 // indirect\n\tgithub.com/go-playground/locales v0.14.1 // indirect\n\tgithub.com/go-playground/universal-translator v0.18.1 // indirect\n\tgithub.com/go-playground/validator/v10 v10.27.0 // indirect\n\tgithub.com/go-viper/mapstructure/v2 v2.4.0 // indirect\n\tgithub.com/goccy/go-json v0.10.5 // indirect\n\tgithub.com/goccy/go-yaml v1.18.0 // indirect\n\tgithub.com/jackc/pgpassfile v1.0.0 // indirect\n\tgithub.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect\n\tgithub.com/jackc/puddle/v2 v2.2.2 // indirect\n\tgithub.com/jinzhu/inflection v1.0.0 // indirect\n\tgithub.com/jinzhu/now v1.1.5 // indirect\n\tgithub.com/json-iterator/go v1.1.12 // indirect\n\tgithub.com/klauspost/cpuid/v2 v2.3.0 // indirect\n\tgithub.com/leodido/go-urn v1.4.0 // indirect\n\tgithub.com/mattn/go-isatty v0.0.20 // indirect\n\tgithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect\n\tgithub.com/modern-go/reflect2 v1.0.2 // indirect\n\tgithub.com/pelletier/go-toml/v2 v2.2.4 // indirect\n\tgithub.com/quic-go/qpack v0.5.1 // indirect\n\tgithub.com/quic-go/quic-go v0.54.0 // indirect\n\tgithub.com/sagikazarmark/locafero v0.11.0 // indirect\n\tgithub.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect\n\tgithub.com/spf13/afero v1.15.0 // indirect\n\tgithub.com/spf13/cast v1.10.0 // indirect\n\tgithub.com/spf13/pflag v1.0.10 // indirect\n\tgithub.com/subosito/gotenv v1.6.0 // indirect\n\tgithub.com/twitchyliquid64/golang-asm v0.15.1 // indirect\n\tgithub.com/ugorji/go/codec v1.3.0 // indirect\n\tgo.uber.org/mock v0.5.0 // indirect\n\tgo.uber.org/multierr v1.10.0 // indirect\n\tgo.yaml.in/yaml/v3 v3.0.4 // indirect\n\tgolang.org/x/arch v0.20.0 // indirect\n\tgolang.org/x/crypto v0.40.0 // indirect\n\tgolang.org/x/mod v0.26.0 // indirect\n\tgolang.org/x/net v0.42.0 // indirect\n\tgolang.org/x/sync v0.16.0 // indirect\n\tgolang.org/x/sys v0.35.0 // indirect\n\tgolang.org/x/text v0.28.0 // indirect\n\tgolang.org/x/tools v0.35.0 // indirect\n\tgoogle.golang.org/protobuf v1.36.9 // indirect\n)\n", - "internal/domain/account/entity.go": "package account\n\nimport (\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n)\n\n// Роли пользователей\ntype Role string\n\nconst (\n\tRoleOwner Role = \"OWNER\" // Создатель: Полный доступ + удаление сервера\n\tRoleAdmin Role = \"ADMIN\" // Администратор: Редактирование, настройки, приглашение\n\tRoleOperator Role = \"OPERATOR\" // Оператор: Только загрузка фото\n)\n\n// User - Пользователь системы (Telegram аккаунт)\ntype User struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;default:gen_random_uuid()\" json:\"id\"`\n\tTelegramID int64 `gorm:\"uniqueIndex;not null\" json:\"telegram_id\"`\n\tUsername string `gorm:\"type:varchar(100)\" json:\"username\"`\n\tFirstName string `gorm:\"type:varchar(100)\" json:\"first_name\"`\n\tLastName string `gorm:\"type:varchar(100)\" json:\"last_name\"`\n\tPhotoURL string `gorm:\"type:text\" json:\"photo_url\"`\n\n\tIsSystemAdmin bool `gorm:\"default:false\" json:\"is_system_admin\"`\n\n\tCreatedAt time.Time `json:\"created_at\"`\n\tUpdatedAt time.Time `json:\"updated_at\"`\n}\n\n// ServerUser - Связь пользователя с сервером (здесь храним личные креды)\ntype ServerUser struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;default:gen_random_uuid()\"`\n\tServerID uuid.UUID `gorm:\"type:uuid;not null;index:idx_user_server,unique\"`\n\tUserID uuid.UUID `gorm:\"type:uuid;not null;index:idx_user_server,unique\"`\n\n\tRole Role `gorm:\"type:varchar(20);default:'OPERATOR'\"`\n\tIsActive bool `gorm:\"default:false\"` // Выбран ли этот сервер сейчас\n\n\t// Персональные данные для подключения (могут быть null у операторов)\n\tLogin string `gorm:\"type:varchar(100)\"`\n\tEncryptedPassword string `gorm:\"type:text\"`\n\n\tServer RMSServer `gorm:\"foreignKey:ServerID\"`\n\tUser User `gorm:\"foreignKey:UserID\"`\n\n\tCreatedAt time.Time `json:\"created_at\"`\n\tUpdatedAt time.Time `json:\"updated_at\"`\n}\n\n// RMSServer - Инстанс сервера iiko\ntype RMSServer struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;default:gen_random_uuid()\" json:\"id\"`\n\n\t// Уникальный URL (очищенный), определяет инстанс\n\tBaseURL string `gorm:\"type:varchar(255);not null;uniqueIndex\" json:\"base_url\"`\n\n\tName string `gorm:\"type:varchar(100);not null\" json:\"name\"`\n\tMaxUsers int `gorm:\"default:5\" json:\"max_users\"` // Лимит пользователей\n\n\t// Глобальные настройки сервера (общие для всех)\n\tDefaultStoreID *uuid.UUID `gorm:\"type:uuid\" json:\"default_store_id\"`\n\tRootGroupGUID *uuid.UUID `gorm:\"type:uuid\" json:\"root_group_guid\"`\n\tAutoProcess bool `gorm:\"default:false\" json:\"auto_process\"`\n\n\t// Billing\n\tBalance int `gorm:\"default:0\" json:\"balance\"`\n\tPaidUntil *time.Time `json:\"paid_until\"`\n\n\t// Stats\n\tInvoiceCount int `gorm:\"default:0\" json:\"invoice_count\"`\n\n\tCreatedAt time.Time `json:\"created_at\"`\n\tUpdatedAt time.Time `json:\"updated_at\"`\n}\n\n// Repository интерфейс\ntype Repository interface {\n\t// Users\n\tGetOrCreateUser(telegramID int64, username, first, last string) (*User, error)\n\tGetUserByTelegramID(telegramID int64) (*User, error)\n\tGetUserByID(id uuid.UUID) (*User, error)\n\n\t// ConnectServer - Основной метод подключения.\n\tConnectServer(userID uuid.UUID, url, login, encryptedPass, name string) (*RMSServer, error)\n\tGetServerByURL(url string) (*RMSServer, error)\n\tGetServerByID(id uuid.UUID) (*RMSServer, error)\n\n\tSaveServerSettings(server *RMSServer) error\n\n\t// SetActiveServer переключает активность в таблице ServerUser\n\tSetActiveServer(userID, serverID uuid.UUID) error\n\n\t// GetActiveServer ищет сервер, где у UserID стоит флаг IsActive=true\n\tGetActiveServer(userID uuid.UUID) (*RMSServer, error)\n\n\t// GetActiveConnectionCredentials возвращает актуальные логин/пароль для текущего юзера (личные или общие)\n\tGetActiveConnectionCredentials(userID uuid.UUID) (url, login, passHash string, err error)\n\n\t// GetAllAvailableServers возвращает все серверы, доступные пользователю (в любом статусе)\n\tGetAllAvailableServers(userID uuid.UUID) ([]RMSServer, error)\n\tDeleteServer(serverID uuid.UUID) error\n\n\t// GetUserRole возвращает роль пользователя на сервере (или ошибку доступа)\n\tGetUserRole(userID, serverID uuid.UUID) (Role, error)\n\tSetUserRole(serverID, targetUserID uuid.UUID, newRole Role) error\n\tGetServerUsers(serverID uuid.UUID) ([]ServerUser, error)\n\n\t// Invite System\n\tAddUserToServer(serverID, userID uuid.UUID, role Role) error\n\tRemoveUserFromServer(serverID, userID uuid.UUID) error\n\n\t// Billing & Stats\n\tIncrementInvoiceCount(serverID uuid.UUID) error\n\tUpdateBalance(serverID uuid.UUID, amountChange int, newPaidUntil *time.Time) error\n\tDecrementBalance(serverID uuid.UUID) error\n\n\t// Super Admin Functions\n\tGetAllServersSystemWide() ([]RMSServer, error)\n\tTransferOwnership(serverID, newOwnerID uuid.UUID) error\n\n\t// GetConnectionByID получает связь ServerUser по её ID (нужно для админки, чтобы сократить callback_data)\n\tGetConnectionByID(id uuid.UUID) (*ServerUser, error)\n}\n", - "internal/domain/billing/entity.go": "// internal/domain/billing/entity.go\n\npackage billing\n\nimport (\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n)\n\ntype TariffType string\n\nconst (\n\tTariffPack TariffType = \"PACK\" // Пакет накладных\n\tTariffSubscription TariffType = \"SUBSCRIPTION\" // Подписка (безлимит на время)\n)\n\ntype OrderStatus string\n\nconst (\n\tStatusPending OrderStatus = \"PENDING\"\n\tStatusPaid OrderStatus = \"PAID\"\n\tStatusCanceled OrderStatus = \"CANCELED\"\n)\n\n// Tariff - Описание услуги\ntype Tariff struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tType TariffType `json:\"type\"`\n\tPrice float64 `json:\"price\"`\n\tInvoicesCount int `json:\"invoices_count\"` // Для PACK - сколько штук, для SUBSCRIPTION - 1000 (лимит)\n\tDurationDays int `json:\"duration_days\"`\n}\n\n// Order - Заказ на покупку тарифа\ntype Order struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;default:gen_random_uuid()\" json:\"id\"`\n\tUserID uuid.UUID `gorm:\"type:uuid;not null;index\" json:\"user_id\"` // Кто платит\n\tTargetServerID uuid.UUID `gorm:\"type:uuid;not null;index\" json:\"target_server_id\"` // Кому начисляем\n\tTariffID string `gorm:\"type:varchar(50);not null\" json:\"tariff_id\"`\n\tAmount float64 `gorm:\"type:numeric(19,4);not null\" json:\"amount\"`\n\tStatus OrderStatus `gorm:\"type:varchar(20);default:'PENDING'\" json:\"status\"`\n\n\tPaymentID string `gorm:\"type:varchar(100)\" json:\"payment_id\"` // ID транзакции в ЮКассе\n\n\tCreatedAt time.Time `json:\"created_at\"`\n\tUpdatedAt time.Time `json:\"updated_at\"`\n}\n\n// Repository для работы с заказами\ntype Repository interface {\n\tCreateOrder(order *Order) error\n\tGetOrder(id uuid.UUID) (*Order, error)\n\tUpdateOrderStatus(id uuid.UUID, status OrderStatus, paymentID string) error\n\tGetActiveOrdersByUser(userID uuid.UUID) ([]Order, error)\n}\n", - "internal/domain/catalog/entity.go": "package catalog\n\nimport (\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/shopspring/decimal\"\n)\n\n// MeasureUnit - Единица измерения (kg, l, pcs)\ntype MeasureUnit struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;\" json:\"id\"`\n\tRMSServerID uuid.UUID `gorm:\"type:uuid;not null;index\" json:\"-\"`\n\tName string `gorm:\"type:varchar(50);not null\" json:\"name\"`\n\tCode string `gorm:\"type:varchar(50)\" json:\"code\"`\n}\n\n// ProductContainer - Фасовка (упаковка) товара\ntype ProductContainer struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;\" json:\"id\"`\n\tRMSServerID uuid.UUID `gorm:\"type:uuid;not null;index\" json:\"-\"`\n\tProductID uuid.UUID `gorm:\"type:uuid;index;not null\" json:\"product_id\"`\n\tName string `gorm:\"type:varchar(100);not null\" json:\"name\"`\n\tCount decimal.Decimal `gorm:\"type:numeric(19,4);not null\" json:\"count\"` // Коэфф. пересчета\n}\n\n// Product - Номенклатура\ntype Product struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;\" json:\"id\"`\n\tRMSServerID uuid.UUID `gorm:\"type:uuid;not null;index\" json:\"-\"`\n\tParentID *uuid.UUID `gorm:\"type:uuid;index\" json:\"parent_id\"`\n\tName string `gorm:\"type:varchar(255);not null\" json:\"name\"`\n\tType string `gorm:\"type:varchar(50);index\" json:\"type\"` // GOODS, DISH, PREPARED\n\tNum string `gorm:\"type:varchar(50)\" json:\"num\"`\n\tCode string `gorm:\"type:varchar(50)\" json:\"code\"`\n\tUnitWeight decimal.Decimal `gorm:\"type:numeric(19,4)\" json:\"unit_weight\"`\n\tUnitCapacity decimal.Decimal `gorm:\"type:numeric(19,4)\" json:\"unit_capacity\"`\n\n\t// Связь с единицей измерения\n\tMainUnitID *uuid.UUID `gorm:\"type:uuid;index\" json:\"main_unit_id\"`\n\tMainUnit *MeasureUnit `gorm:\"foreignKey:MainUnitID\" json:\"main_unit,omitempty\"`\n\n\t// Фасовки\n\tContainers []ProductContainer `gorm:\"foreignKey:ProductID;constraint:OnDelete:CASCADE\" json:\"containers,omitempty\"`\n\n\tIsDeleted bool `gorm:\"default:false\" json:\"is_deleted\"`\n\n\tParent *Product `gorm:\"foreignKey:ParentID\" json:\"-\"`\n\tChildren []*Product `gorm:\"foreignKey:ParentID\" json:\"-\"`\n\n\tCreatedAt time.Time `json:\"created_at\"`\n\tUpdatedAt time.Time `json:\"updated_at\"`\n}\n\n// Repository интерфейс для каталога\ntype Repository interface {\n\tSaveMeasureUnits(units []MeasureUnit) error\n\tSaveProducts(products []Product) error\n\tSaveContainer(container ProductContainer) error\n\n\tSearch(serverID uuid.UUID, query string, rootGroupID *uuid.UUID) ([]Product, error)\n\tGetActiveGoods(serverID uuid.UUID, rootGroupID *uuid.UUID) ([]Product, error)\n\n\tGetGroups(serverID uuid.UUID) ([]Product, error)\n\n\tSaveStores(stores []Store) error\n\tGetActiveStores(serverID uuid.UUID) ([]Store, error)\n\n\tCountGoods(serverID uuid.UUID) (int64, error)\n\tCountStores(serverID uuid.UUID) (int64, error)\n}\n", - "internal/domain/catalog/store.go": "package catalog\n\nimport (\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n)\n\n// Store - Склад\ntype Store struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;\" json:\"id\"`\n\tRMSServerID uuid.UUID `gorm:\"type:uuid;not null;index\" json:\"-\"`\n\n\tName string `gorm:\"type:varchar(255);not null\" json:\"name\"`\n\tParentCorporateID uuid.UUID `gorm:\"type:uuid;index\" json:\"parent_corporate_id\"`\n\tIsDeleted bool `gorm:\"default:false\" json:\"is_deleted\"`\n\n\tCreatedAt time.Time `json:\"created_at\"`\n\tUpdatedAt time.Time `json:\"updated_at\"`\n}\n", - "internal/domain/drafts/entity.go": "package drafts\n\nimport (\n\t\"time\"\n\n\t\"rmser/internal/domain/catalog\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/shopspring/decimal\"\n)\n\nconst (\n\tStatusProcessing = \"PROCESSING\"\n\tStatusReadyToVerify = \"READY_TO_VERIFY\"\n\tStatusCompleted = \"COMPLETED\"\n\tStatusError = \"ERROR\"\n\tStatusCanceled = \"CANCELED\"\n\tStatusDeleted = \"DELETED\"\n)\n\ntype EditedField string\n\nconst (\n\tFieldQuantity EditedField = \"quantity\"\n\tFieldPrice EditedField = \"price\"\n\tFieldSum EditedField = \"sum\"\n)\n\ntype DraftInvoice struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;default:gen_random_uuid()\" json:\"id\"`\n\n\t// Привязка к аккаунту и серверу\n\tUserID uuid.UUID `gorm:\"type:uuid;not null;index\" json:\"user_id\"` // Кто загрузил (автор)\n\tRMSServerID uuid.UUID `gorm:\"type:uuid;not null;index\" json:\"rms_server_id\"` // К какому серверу относится\n\n\tSenderPhotoURL string `gorm:\"type:text\" json:\"photo_url\"`\n\tStatus string `gorm:\"type:varchar(50);default:'PROCESSING'\" json:\"status\"`\n\n\tDocumentNumber string `gorm:\"type:varchar(100)\" json:\"document_number\"`\n\t// Входящий номер документа\n\tIncomingDocumentNumber string `gorm:\"type:varchar(100)\" json:\"incoming_document_number\"`\n\tDateIncoming *time.Time `json:\"date_incoming\"`\n\tSupplierID *uuid.UUID `gorm:\"type:uuid\" json:\"supplier_id\"`\n\n\tStoreID *uuid.UUID `gorm:\"type:uuid\" json:\"store_id\"`\n\tStore *catalog.Store `gorm:\"foreignKey:StoreID\" json:\"store,omitempty\"`\n\n\tComment string `gorm:\"type:text\" json:\"comment\"`\n\tRMSInvoiceID *uuid.UUID `gorm:\"type:uuid\" json:\"rms_invoice_id\"`\n\n\tItems []DraftInvoiceItem `gorm:\"foreignKey:DraftID;constraint:OnDelete:CASCADE\" json:\"items\"`\n\n\tCreatedAt time.Time `json:\"created_at\"`\n\tUpdatedAt time.Time `json:\"updated_at\"`\n}\n\ntype DraftInvoiceItem struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;default:gen_random_uuid()\" json:\"id\"`\n\tDraftID uuid.UUID `gorm:\"type:uuid;not null;index\" json:\"draft_id\"`\n\n\tRawName string `gorm:\"type:varchar(255);not null\" json:\"raw_name\"`\n\tRawAmount decimal.Decimal `gorm:\"type:numeric(19,4)\" json:\"raw_amount\"`\n\tRawPrice decimal.Decimal `gorm:\"type:numeric(19,4)\" json:\"raw_price\"`\n\n\tProductID *uuid.UUID `gorm:\"type:uuid;index\" json:\"product_id\"`\n\tProduct *catalog.Product `gorm:\"foreignKey:ProductID\" json:\"product,omitempty\"`\n\tContainerID *uuid.UUID `gorm:\"type:uuid;index\" json:\"container_id\"`\n\tContainer *catalog.ProductContainer `gorm:\"foreignKey:ContainerID\" json:\"container,omitempty\"`\n\n\tQuantity decimal.Decimal `gorm:\"type:numeric(19,4);default:0\" json:\"quantity\"`\n\tPrice decimal.Decimal `gorm:\"type:numeric(19,4);default:0\" json:\"price\"`\n\tSum decimal.Decimal `gorm:\"type:numeric(19,4);default:0\" json:\"sum\"`\n\n\t// Два последних отредактированных поля (для автопересчёта)\n\tLastEditedField1 EditedField `gorm:\"column:last_edited_field1;type:varchar(20);default:'quantity'\" json:\"last_edited_field_1\"`\n\tLastEditedField2 EditedField `gorm:\"column:last_edited_field2;type:varchar(20);default:'price'\" json:\"last_edited_field_2\"`\n\n\tIsMatched bool `gorm:\"default:false\" json:\"is_matched\"`\n}\n\ntype Repository interface {\n\tCreate(draft *DraftInvoice) error\n\tGetByID(id uuid.UUID) (*DraftInvoice, error)\n\tGetByRMSInvoiceID(rmsInvoiceID uuid.UUID) (*DraftInvoice, error)\n\tGetItemByID(itemID uuid.UUID) (*DraftInvoiceItem, error)\n\tCreateItems(items []DraftInvoiceItem) error\n\tUpdate(draft *DraftInvoice) error\n\tUpdateItem(itemID uuid.UUID, updates map[string]interface{}) error\n\tCreateItem(item *DraftInvoiceItem) error\n\tDeleteItem(itemID uuid.UUID) error\n\tDelete(id uuid.UUID) error\n\n\t// GetActive возвращает активные черновики для СЕРВЕРА (а не юзера)\n\tGetActive(serverID uuid.UUID) ([]DraftInvoice, error)\n\n\t// GetRMSInvoiceIDToPhotoURLMap возвращает мапу rms_invoice_id -> sender_photo_url для сервера, где rms_invoice_id не NULL\n\tGetRMSInvoiceIDToPhotoURLMap(serverID uuid.UUID) (map[uuid.UUID]string, error)\n}\n", - "internal/domain/interfaces.go": "package domain\n\nimport (\n\t\"rmser/internal/domain/catalog\"\n\t\"rmser/internal/domain/invoices\"\n\t\"rmser/internal/domain/recipes\"\n\t\"time\"\n)\n\ntype Repository interface {\n\t// Catalog\n\tSaveProducts(products []catalog.Product) error\n\n\t// Recipes\n\tSaveRecipes(recipes []recipes.Recipe) error\n\n\t// Invoices\n\tGetLastInvoiceDate() (*time.Time, error)\n\tSaveInvoices(invoices []invoices.Invoice) error\n}\n", - "internal/domain/invoices/entity.go": "package invoices\n\nimport (\n\t\"time\"\n\n\t\"rmser/internal/domain/catalog\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/shopspring/decimal\"\n)\n\n// Invoice - Приходная накладная\ntype Invoice struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;\"`\n\tRMSServerID uuid.UUID `gorm:\"type:uuid;not null;index\"`\n\tDocumentNumber string `gorm:\"type:varchar(100);index\"`\n\tIncomingDocumentNumber string `gorm:\"type:varchar(100)\"`\n\tDateIncoming time.Time `gorm:\"index\"`\n\tSupplierID uuid.UUID `gorm:\"type:uuid;index\"`\n\tDefaultStoreID uuid.UUID `gorm:\"type:uuid;index\"`\n\tStatus string `gorm:\"type:varchar(50)\"`\n\tComment string `gorm:\"type:text\"`\n\n\tItems []InvoiceItem `gorm:\"foreignKey:InvoiceID;constraint:OnDelete:CASCADE\"`\n\n\tCreatedAt time.Time\n\tUpdatedAt time.Time\n}\n\n// InvoiceItem - Позиция накладной\ntype InvoiceItem struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;default:gen_random_uuid()\"`\n\tInvoiceID uuid.UUID `gorm:\"type:uuid;not null;index\"`\n\tProductID uuid.UUID `gorm:\"type:uuid;not null\"`\n\tContainerID *uuid.UUID `gorm:\"type:uuid\"`\n\tAmount decimal.Decimal `gorm:\"type:numeric(19,4);not null\"`\n\tPrice decimal.Decimal `gorm:\"type:numeric(19,4);not null\"`\n\tSum decimal.Decimal `gorm:\"type:numeric(19,4);not null\"`\n\tVatSum decimal.Decimal `gorm:\"type:numeric(19,4)\"`\n\n\tProduct catalog.Product `gorm:\"foreignKey:ProductID\"`\n}\n\ntype Repository interface {\n\tGetByID(id uuid.UUID) (*Invoice, error)\n\tGetLastInvoiceDate(serverID uuid.UUID) (*time.Time, error)\n\tGetByPeriod(serverID uuid.UUID, from, to time.Time) ([]Invoice, error)\n\tSaveInvoices(invoices []Invoice) error\n\tCountRecent(serverID uuid.UUID, days int) (int64, error)\n}\n", - "internal/domain/ocr/entity.go": "package ocr\n\nimport (\n\t\"time\"\n\n\t\"rmser/internal/domain/catalog\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/shopspring/decimal\"\n)\n\n// ProductMatch\ntype ProductMatch struct {\n\t// RawName больше не PrimaryKey, так как в разных серверах один текст может значить разное\n\t// Делаем составной ключ или суррогатный ID.\n\t// Для простоты GORM: ID - uuid, а уникальность через индекс (RawName + ServerID)\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;default:gen_random_uuid()\"`\n\tRMSServerID uuid.UUID `gorm:\"type:uuid;not null;index:idx_raw_server,unique\"`\n\n\tRawName string `gorm:\"type:varchar(255);not null;index:idx_raw_server,unique\" json:\"raw_name\"`\n\tProductID uuid.UUID `gorm:\"type:uuid;not null;index\" json:\"product_id\"`\n\tProduct catalog.Product `gorm:\"foreignKey:ProductID\" json:\"product\"`\n\n\tQuantity decimal.Decimal `gorm:\"type:numeric(19,4);default:1\" json:\"quantity\"`\n\tContainerID *uuid.UUID `gorm:\"type:uuid;index\" json:\"container_id\"`\n\tContainer *catalog.ProductContainer `gorm:\"foreignKey:ContainerID\" json:\"container,omitempty\"`\n\n\tUpdatedAt time.Time `json:\"updated_at\"`\n\tCreatedAt time.Time `json:\"created_at\"`\n}\n\n// UnmatchedItem тоже стоит делить, чтобы подсказывать пользователю только его нераспознанные,\n// хотя глобальная база unmatched может быть полезна для аналитики.\n// Сделаем раздельной для чистоты SaaS.\ntype UnmatchedItem struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;default:gen_random_uuid()\"`\n\tRMSServerID uuid.UUID `gorm:\"type:uuid;not null;index:idx_unm_raw_server,unique\"`\n\n\tRawName string `gorm:\"type:varchar(255);not null;index:idx_unm_raw_server,unique\" json:\"raw_name\"`\n\tCount int `gorm:\"default:1\" json:\"count\"`\n\tLastSeen time.Time `json:\"last_seen\"`\n}\n\ntype Repository interface {\n\tSaveMatch(serverID uuid.UUID, rawName string, productID uuid.UUID, quantity decimal.Decimal, containerID *uuid.UUID) error\n\tDeleteMatch(serverID uuid.UUID, rawName string) error\n\tFindMatch(serverID uuid.UUID, rawName string) (*ProductMatch, error)\n\tGetAllMatches(serverID uuid.UUID) ([]ProductMatch, error)\n\n\tUpsertUnmatched(serverID uuid.UUID, rawName string) error\n\tGetTopUnmatched(serverID uuid.UUID, limit int) ([]UnmatchedItem, error)\n\tDeleteUnmatched(serverID uuid.UUID, rawName string) error\n}\n", - "internal/domain/operations/entity.go": "package operations\n\nimport (\n\t\"time\"\n\n\t\"rmser/internal/domain/catalog\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/shopspring/decimal\"\n)\n\ntype OperationType string\n\nconst (\n\tOpTypePurchase OperationType = \"PURCHASE\" // Закупка (Приход)\n\tOpTypeUsage OperationType = \"USAGE\" // Расход (Реализация + Списание)\n\tOpTypeUnknown OperationType = \"UNKNOWN\" // Прочее (Инвентаризация, Перемещения - игнорируем пока)\n)\n\n// StoreOperation - запись из складского отчета\ntype StoreOperation struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;default:gen_random_uuid()\"`\n\tRMSServerID uuid.UUID `gorm:\"type:uuid;not null;index\"`\n\tProductID uuid.UUID `gorm:\"type:uuid;not null;index\"`\n\n\t// Наш внутренний, \"очищенный\" тип операции\n\tOpType OperationType `gorm:\"type:varchar(50);index\"`\n\n\t// Raw данные из iiko для отладки и детализации\n\tDocumentType string `gorm:\"type:varchar(100);index\"` // INCOMING_INVOICE, etc.\n\tTransactionType string `gorm:\"type:varchar(100)\"` // INVOICE, WRITEOFF, etc.\n\tDocumentNumber string `gorm:\"type:varchar(100)\"`\n\n\tAmount decimal.Decimal `gorm:\"type:numeric(19,4)\"`\n\tSum decimal.Decimal `gorm:\"type:numeric(19,4)\"`\n\tCost decimal.Decimal `gorm:\"type:numeric(19,4)\"`\n\n\t// Период синхронизации (для перезаписи данных)\n\tPeriodFrom time.Time `gorm:\"index\"`\n\tPeriodTo time.Time `gorm:\"index\"`\n\n\tProduct catalog.Product `gorm:\"foreignKey:ProductID\"`\n\n\tCreatedAt time.Time\n}\n\ntype Repository interface {\n\tSaveOperations(ops []StoreOperation, serverID uuid.UUID, opType OperationType, dateFrom, dateTo time.Time) error\n}\n", - "internal/domain/photos/entity.go": "package photos\n\nimport (\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n)\n\ntype PhotoStatus string\n\nconst (\n\tPhotoStatusOrphan PhotoStatus = \"ORPHAN\" // Нет связанного черновика\n\tPhotoStatusHasDraft PhotoStatus = \"HAS_DRAFT\" // Есть черновик\n\tPhotoStatusHasInvoice PhotoStatus = \"HAS_INVOICE\" // Есть накладная в iiko\n)\n\n// ReceiptPhoto - фото чека\ntype ReceiptPhoto struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;default:gen_random_uuid()\" json:\"id\"`\n\tRMSServerID uuid.UUID `gorm:\"type:uuid;not null;index\" json:\"rms_server_id\"`\n\tUploadedBy uuid.UUID `gorm:\"type:uuid;not null\" json:\"uploaded_by\"` // User ID\n\n\tFilePath string `gorm:\"type:varchar(500);not null\" json:\"file_path\"`\n\tFileURL string `gorm:\"type:varchar(500);not null\" json:\"file_url\"` // Public URL\n\tFileName string `gorm:\"type:varchar(255)\" json:\"file_name\"`\n\tFileSize int64 `json:\"file_size\"`\n\n\t// Связи (указатели, так как могут быть null)\n\tDraftID *uuid.UUID `gorm:\"type:uuid;index\" json:\"draft_id\"`\n\tInvoiceID *uuid.UUID `gorm:\"type:uuid;index\" json:\"invoice_id\"` // RMS Invoice ID (когда накладная создана)\n\n\t// Метаданные OCR (чтобы можно было пересоздать черновик без повторного запроса к нейросети)\n\tOCRSource string `gorm:\"type:varchar(50)\" json:\"ocr_source\"` // qr_api, yandex, etc.\n\tOCRRawText string `gorm:\"type:text\" json:\"ocr_raw_text\"`\n\n\tCreatedAt time.Time `json:\"created_at\"`\n\tUpdatedAt time.Time `json:\"updated_at\"`\n}\n\n// Repository interface\ntype Repository interface {\n\tCreate(photo *ReceiptPhoto) error\n\tGetByID(id uuid.UUID) (*ReceiptPhoto, error)\n\t// GetByServerID возвращает фото с пагинацией\n\tGetByServerID(serverID uuid.UUID, page, limit int) ([]ReceiptPhoto, int64, error)\n\n\tUpdateDraftLink(photoID uuid.UUID, draftID *uuid.UUID) error\n\tUpdateInvoiceLink(photoID uuid.UUID, invoiceID *uuid.UUID) error\n\n\t// ClearDraftLinkByDraftID очищает ссылку на черновик (когда черновик удаляется)\n\tClearDraftLinkByDraftID(draftID uuid.UUID) error\n\n\tDelete(id uuid.UUID) error\n}\n", - "internal/domain/recipes/entity.go": "package recipes\n\nimport (\n\t\"time\"\n\n\t\"rmser/internal/domain/catalog\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/shopspring/decimal\"\n)\n\n// Recipe - Технологическая карта\ntype Recipe struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;\"`\n\tRMSServerID uuid.UUID `gorm:\"type:uuid;not null;index\"`\n\tProductID uuid.UUID `gorm:\"type:uuid;not null;index\"`\n\tDateFrom time.Time `gorm:\"index\"`\n\tDateTo *time.Time\n\n\tProduct catalog.Product `gorm:\"foreignKey:ProductID\"`\n\tItems []RecipeItem `gorm:\"foreignKey:RecipeID;constraint:OnDelete:CASCADE\"`\n}\n\n// RecipeItem - Ингредиент\ntype RecipeItem struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;default:gen_random_uuid()\"`\n\tRMSServerID uuid.UUID `gorm:\"type:uuid;not null;index\"`\n\tRecipeID uuid.UUID `gorm:\"type:uuid;not null;index\"`\n\tProductID uuid.UUID `gorm:\"type:uuid;not null;index\"`\n\tAmountIn decimal.Decimal `gorm:\"type:numeric(19,4);not null\"`\n\tAmountOut decimal.Decimal `gorm:\"type:numeric(19,4);not null\"`\n\n\tProduct catalog.Product `gorm:\"foreignKey:ProductID\"`\n}\n\ntype Repository interface {\n\tSaveRecipes(recipes []Recipe) error\n}\n", - "internal/domain/recommendations/entity.go": "package recommendations\n\nimport (\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n)\n\n// Типы проблем\nconst (\n\tTypeUnused = \"UNUSED_IN_RECIPES\" // Товар не используется в техкартах\n\tTypeNoIncoming = \"NO_INCOMING\" // Ингредиент (GOODS) в техкарте, но нет приходов\n\tTypeStale = \"STALE_GOODS\" // Есть приходы, но нет продаж\n\tTypeDishInRecipe = \"DISH_IN_RECIPE\" // Блюдо (DISH) в составе другого блюда\n\tTypePurchasedButUnused = \"PURCHASED_BUT_UNUSED\" // Активно закупается, но нет в техкартах\n\tTypeUsageNoIncoming = \"USAGE_NO_INCOMING\" // Есть расходы, но нет приходов\n)\n\n// Recommendation - Результат анализа\ntype Recommendation struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;default:gen_random_uuid()\"`\n\tType string `gorm:\"type:varchar(50);index\"`\n\tProductID uuid.UUID `gorm:\"type:uuid;index\"`\n\tProductName string `gorm:\"type:varchar(255)\"`\n\tReason string `gorm:\"type:text\"`\n\n\tCreatedAt time.Time\n}\n\n// Repository отвечает за аналитические выборки и хранение результатов\ntype Repository interface {\n\t// Методы анализа (возвращают список структур, но не пишут в БД)\n\tFindUnusedGoods() ([]Recommendation, error)\n\tFindNoIncomingIngredients(days int) ([]Recommendation, error)\n\tFindStaleGoods(days int) ([]Recommendation, error)\n\tFindDishesInRecipes() ([]Recommendation, error)\n\tFindPurchasedButUnused(days int) ([]Recommendation, error)\n\tFindUsageWithoutPurchase(days int) ([]Recommendation, error)\n\n\t// Методы \"Кэша\" в БД\n\tSaveAll(items []Recommendation) error // Удаляет старые и пишет новые\n\tGetAll() ([]Recommendation, error)\n}\n", - "internal/domain/suppliers/entity.go": "package suppliers\n\nimport (\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n)\n\n// Supplier - Поставщик (Контрагент)\ntype Supplier struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;\" json:\"id\"`\n\tName string `gorm:\"type:varchar(255);not null\" json:\"name\"`\n\tCode string `gorm:\"type:varchar(50)\" json:\"code\"`\n\tINN string `gorm:\"type:varchar(20)\" json:\"inn\"` // taxpayerIdNumber\n\n\t// Привязка к конкретному серверу iiko (Multi-tenant)\n\tRMSServerID uuid.UUID `gorm:\"type:uuid;not null;index\" json:\"-\"`\n\n\tIsDeleted bool `gorm:\"default:false\" json:\"is_deleted\"`\n\n\tUpdatedAt time.Time `json:\"updated_at\"`\n}\n\ntype Repository interface {\n\tSaveBatch(suppliers []Supplier) error\n\tGetByID(id uuid.UUID) (*Supplier, error)\n\t// GetRankedByUsage возвращает поставщиков, отсортированных по частоте использования в накладных за N дней\n\tGetRankedByUsage(serverID uuid.UUID, daysLookBack int) ([]Supplier, error)\n\tCount(serverID uuid.UUID) (int64, error)\n}\n", - "internal/infrastructure/db/postgres.go": "package db\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"regexp\"\n\t\"rmser/internal/domain/account\"\n\t\"rmser/internal/domain/billing\"\n\t\"rmser/internal/domain/catalog\"\n\t\"rmser/internal/domain/drafts\"\n\t\"rmser/internal/domain/invoices\"\n\t\"rmser/internal/domain/ocr\"\n\t\"rmser/internal/domain/operations\"\n\t\"rmser/internal/domain/photos\"\n\t\"rmser/internal/domain/recipes\"\n\t\"rmser/internal/domain/recommendations\"\n\t\"rmser/internal/domain/suppliers\"\n\t\"time\"\n\n\t_ \"github.com/jackc/pgx/v5/stdlib\"\n\t\"gorm.io/driver/postgres\"\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/logger\"\n)\n\nfunc NewPostgresDB(dsn string) *gorm.DB {\n\t// 1. Проверка и создание БД перед основным подключением\n\tensureDBExists(dsn)\n\n\t// 2. Настройка логгера GORM\n\tnewLogger := logger.New(\n\t\tlog.New(os.Stdout, \"\\r\\n\", log.LstdFlags),\n\t\tlogger.Config{\n\t\t\tSlowThreshold: time.Second,\n\t\t\tLogLevel: logger.Warn,\n\t\t\tIgnoreRecordNotFoundError: true,\n\t\t\tColorful: true,\n\t\t},\n\t)\n\n\t// 3. Основное подключение\n\tdb, err := gorm.Open(postgres.Open(dsn), &gorm.Config{\n\t\tLogger: newLogger,\n\t})\n\tif err != nil {\n\t\tpanic(fmt.Sprintf(\"не удалось подключиться к БД: %v\", err))\n\t}\n\n\t// 4. Автомиграция\n\terr = db.AutoMigrate(\n\t\t&account.User{},\n\t\t&account.RMSServer{},\n\t\t&account.ServerUser{},\n\t\t&billing.Order{},\n\t\t&catalog.Product{},\n\t\t&catalog.MeasureUnit{},\n\t\t&catalog.ProductContainer{},\n\t\t&catalog.Store{},\n\t\t&suppliers.Supplier{},\n\t\t&recipes.Recipe{},\n\t\t&recipes.RecipeItem{},\n\t\t&invoices.Invoice{},\n\t\t&invoices.InvoiceItem{},\n\t\t&drafts.DraftInvoice{},\n\t\t&drafts.DraftInvoiceItem{},\n\t\t&operations.StoreOperation{},\n\t\t&recommendations.Recommendation{},\n\t\t&ocr.ProductMatch{},\n\t\t&ocr.UnmatchedItem{},\n\t\t&photos.ReceiptPhoto{},\n\t)\n\tif err != nil {\n\t\tpanic(fmt.Sprintf(\"ошибка миграции БД: %v\", err))\n\t}\n\n\treturn db\n}\n\n// ensureDBExists подключается к системной БД 'postgres' и создает целевую, если её нет\nfunc ensureDBExists(fullDSN string) {\n\t// Регулярка для извлечения имени базы из DSN (ищем dbname=... )\n\tre := regexp.MustCompile(`dbname=([^\\s]+)`)\n\tmatches := re.FindStringSubmatch(fullDSN)\n\n\tif len(matches) < 2 {\n\t\t// Если не нашли dbname, возможно формат URL (postgres://...),\n\t\t// пропускаем авто-создание, полагаемся на ошибку драйвера\n\t\treturn\n\t}\n\n\ttargetDB := matches[1]\n\n\t// Заменяем целевую БД на системную 'postgres' для подключения\n\tmaintenanceDSN := re.ReplaceAllString(fullDSN, \"dbname=postgres\")\n\n\t// Используем стандартный sql драйвер через pgx (который под капотом у gorm/postgres)\n\t// Важно: нам не нужен GORM здесь, нужен чистый SQL для CREATE DATABASE\n\tdb, err := sql.Open(\"pgx\", maintenanceDSN)\n\tif err != nil {\n\t\t// Если не вышло подключиться к postgres, просто выходим,\n\t\t// основная ошибка вылетит при попытке gorm.Open\n\t\tlog.Printf(\"[WARN] Не удалось подключиться к системной БД для проверки: %v\", err)\n\t\treturn\n\t}\n\tdefer db.Close()\n\n\t// Проверяем существование базы\n\tvar exists bool\n\tcheckSQL := fmt.Sprintf(\"SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = '%s')\", targetDB)\n\terr = db.QueryRow(checkSQL).Scan(&exists)\n\tif err != nil {\n\t\tlog.Printf(\"[WARN] Ошибка проверки существования БД: %v\", err)\n\t\treturn\n\t}\n\n\tif !exists {\n\t\tlog.Printf(\"[INFO] База данных '%s' не найдена. Создаю...\", targetDB)\n\t\t// CREATE DATABASE не может быть выполнен в транзакции, поэтому Exec\n\t\t_, err = db.Exec(fmt.Sprintf(\"CREATE DATABASE \\\"%s\\\"\", targetDB))\n\t\tif err != nil {\n\t\t\tpanic(fmt.Sprintf(\"не удалось создать базу данных %s: %v\", targetDB, err))\n\t\t}\n\t\tlog.Printf(\"[INFO] База данных '%s' успешно создана\", targetDB)\n\t}\n}\n", - "internal/infrastructure/ocr_client/client.go": "package ocr_client\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"mime/multipart\"\n\t\"net/http\"\n\t\"net/textproto\"\n\t\"time\"\n)\n\ntype Client struct {\n\tpythonServiceURL string\n\thttpClient *http.Client\n}\n\nfunc NewClient(pythonServiceURL string) *Client {\n\treturn &Client{\n\t\tpythonServiceURL: pythonServiceURL,\n\t\thttpClient: &http.Client{\n\t\t\t// OCR может быть долгим, ставим таймаут побольше (например, 30 сек)\n\t\t\tTimeout: 30 * time.Second,\n\t\t},\n\t}\n}\n\n// ProcessImage отправляет изображение в Python и возвращает сырые данные\nfunc (c *Client) ProcessImage(ctx context.Context, imageData []byte, filename string) (*RecognitionResult, error) {\n\t// 1. Создаем буфер для multipart формы\n\tbody := &bytes.Buffer{}\n\twriter := multipart.NewWriter(body)\n\n\t// Создаем заголовок части вручную, чтобы прописать Content-Type: image/jpeg\n\th := make(textproto.MIMEHeader)\n\th.Set(\"Content-Disposition\", fmt.Sprintf(`form-data; name=\"image\"; filename=\"%s\"`, filename))\n\th.Set(\"Content-Type\", \"image/jpeg\") // Явно указываем, что это картинка\n\n\tpart, err := writer.CreatePart(h)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"create part error: %w\", err)\n\t}\n\n\t// Записываем байты картинки\n\tif _, err := io.Copy(part, bytes.NewReader(imageData)); err != nil {\n\t\treturn nil, fmt.Errorf(\"copy file error: %w\", err)\n\t}\n\n\t// Закрываем writer, чтобы записать boundary\n\tif err := writer.Close(); err != nil {\n\t\treturn nil, fmt.Errorf(\"writer close error: %w\", err)\n\t}\n\n\t// 2. Создаем запрос\n\treq, err := http.NewRequestWithContext(ctx, \"POST\", c.pythonServiceURL+\"/recognize\", body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"create request error: %w\", err)\n\t}\n\n\t// Важно: Content-Type с boundary\n\treq.Header.Set(\"Content-Type\", writer.FormDataContentType())\n\n\t// 3. Отправляем\n\tresp, err := c.httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"ocr service request failed: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tbodyBytes, _ := io.ReadAll(resp.Body)\n\t\treturn nil, fmt.Errorf(\"ocr service error (code %d): %s\", resp.StatusCode, string(bodyBytes))\n\t}\n\n\t// 4. Парсим ответ\n\tvar result RecognitionResult\n\tif err := json.NewDecoder(resp.Body).Decode(&result); err != nil {\n\t\treturn nil, fmt.Errorf(\"json decode error: %w\", err)\n\t}\n\n\treturn &result, nil\n}\n", - "internal/infrastructure/ocr_client/dto.go": "package ocr_client\n\n// RecognitionResult - ответ от Python сервиса\ntype RecognitionResult struct {\n\tItems []RecognizedItem `json:\"items\"`\n}\n\ntype RecognizedItem struct {\n\tRawName string `json:\"raw_name\"` // Текст названия из чека\n\tAmount float64 `json:\"amount\"` // Кол-во\n\tPrice float64 `json:\"price\"` // Цена\n\tSum float64 `json:\"sum\"` // Сумма\n}\n", - "internal/infrastructure/redis/client.go": "package redis\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/redis/go-redis/v9\"\n)\n\ntype Client struct {\n\trdb *redis.Client\n}\n\nfunc NewClient(addr, password string, dbIndex int) (*Client, error) {\n\trdb := redis.NewClient(&redis.Options{\n\t\tAddr: addr,\n\t\tPassword: password,\n\t\tDB: dbIndex,\n\t})\n\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\tif err := rdb.Ping(ctx).Err(); err != nil {\n\t\treturn nil, fmt.Errorf(\"ошибка подключения к Redis: %w\", err)\n\t}\n\n\treturn &Client{rdb: rdb}, nil\n}\n\n// Set сохраняет значение (структуру) в JSON\nfunc (c *Client) Set(ctx context.Context, key string, value any, ttl time.Duration) error {\n\tbytes, err := json.Marshal(value)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"json marshal error: %w\", err)\n\t}\n\treturn c.rdb.Set(ctx, key, bytes, ttl).Err()\n}\n\n// Get загружает значение в переданный указатель dest\nfunc (c *Client) Get(ctx context.Context, key string, dest any) error {\n\tval, err := c.rdb.Get(ctx, key).Result()\n\tif err != nil {\n\t\tif err == redis.Nil {\n\t\t\treturn nil // Ключ не найден, не считаем ошибкой\n\t\t}\n\t\treturn err\n\t}\n\treturn json.Unmarshal([]byte(val), dest)\n}\n", - "internal/infrastructure/repository/account/postgres.go": "package account\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"rmser/internal/domain/account\"\n\n\t\"github.com/google/uuid\"\n\t\"gorm.io/gorm\"\n)\n\ntype pgRepository struct {\n\tdb *gorm.DB\n}\n\nfunc NewRepository(db *gorm.DB) account.Repository {\n\treturn &pgRepository{db: db}\n}\n\n// GetOrCreateUser находит пользователя или создает нового\nfunc (r *pgRepository) GetOrCreateUser(telegramID int64, username, first, last string) (*account.User, error) {\n\tvar user account.User\n\terr := r.db.Where(\"telegram_id = ?\", telegramID).First(&user).Error\n\n\tif err != nil {\n\t\tif err == gorm.ErrRecordNotFound {\n\t\t\tnewUser := account.User{\n\t\t\t\tTelegramID: telegramID,\n\t\t\t\tUsername: username,\n\t\t\t\tFirstName: first,\n\t\t\t\tLastName: last,\n\t\t\t}\n\t\t\tif err := r.db.Create(&newUser).Error; err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\treturn &newUser, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\n\t// Обновляем инфо\n\tif user.Username != username || user.FirstName != first || user.LastName != last {\n\t\tuser.Username = username\n\t\tuser.FirstName = first\n\t\tuser.LastName = last\n\t\tr.db.Save(&user)\n\t}\n\n\treturn &user, nil\n}\n\nfunc (r *pgRepository) GetUserByTelegramID(telegramID int64) (*account.User, error) {\n\tvar user account.User\n\terr := r.db.Where(\"telegram_id = ?\", telegramID).First(&user).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &user, nil\n}\n\nfunc (r *pgRepository) GetUserByID(id uuid.UUID) (*account.User, error) {\n\tvar user account.User\n\terr := r.db.Where(\"id = ?\", id).First(&user).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &user, nil\n}\n\n// ConnectServer - Основная точка входа для добавления сервера\nfunc (r *pgRepository) ConnectServer(userID uuid.UUID, rawURL, login, encryptedPass, name string) (*account.RMSServer, error) {\n\tcleanURL := strings.TrimRight(strings.ToLower(strings.TrimSpace(rawURL)), \"/\")\n\n\tvar server account.RMSServer\n\tvar created bool\n\n\terr := r.db.Transaction(func(tx *gorm.DB) error {\n\t\terr := tx.Where(\"base_url = ?\", cleanURL).First(&server).Error\n\t\tif err != nil && err != gorm.ErrRecordNotFound {\n\t\t\treturn err\n\t\t}\n\n\t\tif err == gorm.ErrRecordNotFound {\n\t\t\t// --- СЦЕНАРИЙ 1: НОВЫЙ СЕРВЕР (Приветственный бонус) ---\n\t\t\ttrialDays := 30\n\t\t\twelcomeBalance := 10\n\t\t\tpaidUntil := time.Now().AddDate(0, 0, trialDays)\n\n\t\t\tserver = account.RMSServer{\n\t\t\t\tBaseURL: cleanURL,\n\t\t\t\tName: name,\n\t\t\t\tMaxUsers: 5,\n\t\t\t\tBalance: welcomeBalance,\n\t\t\t\tPaidUntil: &paidUntil,\n\t\t\t}\n\t\t\tif err := tx.Create(&server).Error; err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tcreated = true\n\t\t} else {\n\t\t\t// --- СЦЕНАРИЙ 2: СУЩЕСТВУЮЩИЙ СЕРВЕР ---\n\t\t\tvar userCount int64\n\t\t\ttx.Model(&account.ServerUser{}).Where(\"server_id = ?\", server.ID).Count(&userCount)\n\t\t\tif userCount >= int64(server.MaxUsers) {\n\t\t\t\tvar exists int64\n\t\t\t\ttx.Model(&account.ServerUser{}).Where(\"server_id = ? AND user_id = ?\", server.ID, userID).Count(&exists)\n\t\t\t\tif exists == 0 {\n\t\t\t\t\treturn fmt.Errorf(\"достигнут лимит пользователей на сервере (%d)\", server.MaxUsers)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\ttargetRole := account.RoleOperator\n\t\tif created {\n\t\t\ttargetRole = account.RoleOwner\n\t\t}\n\n\t\tif err := tx.Model(&account.ServerUser{}).Where(\"user_id = ?\", userID).Update(\"is_active\", false).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tvar existingLink account.ServerUser\n\t\terr = tx.Where(\"server_id = ? AND user_id = ?\", server.ID, userID).First(&existingLink).Error\n\t\tif err == nil {\n\t\t\texistingLink.Login = login\n\t\t\texistingLink.EncryptedPassword = encryptedPass\n\t\t\texistingLink.IsActive = true\n\t\t\treturn tx.Save(&existingLink).Error\n\t\t}\n\n\t\tuserLink := account.ServerUser{\n\t\t\tServerID: server.ID,\n\t\t\tUserID: userID,\n\t\t\tRole: targetRole,\n\t\t\tIsActive: true,\n\t\t\tLogin: login,\n\t\t\tEncryptedPassword: encryptedPass,\n\t\t}\n\t\treturn tx.Create(&userLink).Error\n\t})\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &server, nil\n}\n\nfunc (r *pgRepository) SaveServerSettings(server *account.RMSServer) error {\n\treturn r.db.Model(server).Updates(map[string]interface{}{\n\t\t\"name\": server.Name,\n\t\t\"default_store_id\": server.DefaultStoreID,\n\t\t\"root_group_guid\": server.RootGroupGUID,\n\t\t\"auto_process\": server.AutoProcess,\n\t\t\"max_users\": server.MaxUsers,\n\t}).Error\n}\n\nfunc (r *pgRepository) SetActiveServer(userID, serverID uuid.UUID) error {\n\treturn r.db.Transaction(func(tx *gorm.DB) error {\n\t\t// Проверка доступа\n\t\tvar count int64\n\t\ttx.Model(&account.ServerUser{}).Where(\"user_id = ? AND server_id = ?\", userID, serverID).Count(&count)\n\t\tif count == 0 {\n\t\t\treturn errors.New(\"доступ к серверу запрещен\")\n\t\t}\n\n\t\tif err := tx.Model(&account.ServerUser{}).Where(\"user_id = ?\", userID).Update(\"is_active\", false).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn tx.Model(&account.ServerUser{}).Where(\"user_id = ? AND server_id = ?\", userID, serverID).Update(\"is_active\", true).Error\n\t})\n}\n\nfunc (r *pgRepository) GetActiveServer(userID uuid.UUID) (*account.RMSServer, error) {\n\tvar server account.RMSServer\n\terr := r.db.Table(\"rms_servers\").\n\t\tSelect(\"rms_servers.*\").\n\t\tJoins(\"JOIN server_users ON server_users.server_id = rms_servers.id\").\n\t\tWhere(\"server_users.user_id = ? AND server_users.is_active = ?\", userID, true).\n\t\tFirst(&server).Error\n\n\tif err != nil {\n\t\tif err == gorm.ErrRecordNotFound {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\treturn &server, nil\n}\n\n// GetActiveConnectionCredentials возвращает креды для подключения.\n// Логика:\n// 1. Берем личные креды из server_users (если активен)\n// 2. Если личных нет (пустой пароль) -> ищем креды Владельца (Owner) этого сервера\nfunc (r *pgRepository) GetActiveConnectionCredentials(userID uuid.UUID) (url, login, passHash string, err error) {\n\t// 1. Получаем связь текущего юзера с активным сервером\n\ttype Result struct {\n\t\tServerID uuid.UUID\n\t\tBaseURL string\n\t\tLogin string\n\t\tEncryptedPassword string\n\t}\n\tvar res Result\n\n\terr = r.db.Table(\"server_users\").\n\t\tSelect(\"server_users.server_id, rms_servers.base_url, server_users.login, server_users.encrypted_password\").\n\t\tJoins(\"JOIN rms_servers ON rms_servers.id = server_users.server_id\").\n\t\tWhere(\"server_users.user_id = ? AND server_users.is_active = ?\", userID, true).\n\t\tScan(&res).Error\n\n\tif err != nil {\n\t\treturn \"\", \"\", \"\", err\n\t}\n\tif res.ServerID == uuid.Nil {\n\t\treturn \"\", \"\", \"\", errors.New(\"нет активного сервера\")\n\t}\n\n\t// Если есть личные креды - возвращаем их\n\tif res.Login != \"\" && res.EncryptedPassword != \"\" {\n\t\treturn res.BaseURL, res.Login, res.EncryptedPassword, nil\n\t}\n\n\t// 2. Фоллбэк: ищем креды владельца (OWNER)\n\tvar ownerLink account.ServerUser\n\terr = r.db.Where(\"server_id = ? AND role = ?\", res.ServerID, account.RoleOwner).\n\t\tOrder(\"created_at ASC\"). // На случай коллизий, берем старейшего\n\t\tFirst(&ownerLink).Error\n\n\tif err != nil {\n\t\treturn \"\", \"\", \"\", fmt.Errorf(\"у вас нет учетных данных, а владелец сервера не найден: %w\", err)\n\t}\n\n\tif ownerLink.Login == \"\" || ownerLink.EncryptedPassword == \"\" {\n\t\treturn \"\", \"\", \"\", errors.New(\"у владельца сервера отсутствуют учетные данные\")\n\t}\n\n\treturn res.BaseURL, ownerLink.Login, ownerLink.EncryptedPassword, nil\n}\n\nfunc (r *pgRepository) GetAllAvailableServers(userID uuid.UUID) ([]account.RMSServer, error) {\n\tvar servers []account.RMSServer\n\terr := r.db.Table(\"rms_servers\").\n\t\tSelect(\"rms_servers.*\").\n\t\tJoins(\"JOIN server_users ON server_users.server_id = rms_servers.id\").\n\t\tWhere(\"server_users.user_id = ?\", userID).\n\t\tFind(&servers).Error\n\treturn servers, err\n}\n\nfunc (r *pgRepository) DeleteServer(serverID uuid.UUID) error {\n\t// Полное удаление сервера и всех связей\n\treturn r.db.Transaction(func(tx *gorm.DB) error {\n\t\tif err := tx.Where(\"server_id = ?\", serverID).Delete(&account.ServerUser{}).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := tx.Delete(&account.RMSServer{}, serverID).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n}\n\n// --- Управление правами ---\n\nfunc (r *pgRepository) GetUserRole(userID, serverID uuid.UUID) (account.Role, error) {\n\tvar link account.ServerUser\n\terr := r.db.Select(\"role\").Where(\"user_id = ? AND server_id = ?\", userID, serverID).First(&link).Error\n\tif err != nil {\n\t\tif err == gorm.ErrRecordNotFound {\n\t\t\treturn \"\", errors.New(\"access denied\")\n\t\t}\n\t\treturn \"\", err\n\t}\n\treturn link.Role, nil\n}\n\nfunc (r *pgRepository) SetUserRole(serverID, targetUserID uuid.UUID, newRole account.Role) error {\n\treturn r.db.Model(&account.ServerUser{}).\n\t\tWhere(\"server_id = ? AND user_id = ?\", serverID, targetUserID).\n\t\tUpdate(\"role\", newRole).Error\n}\n\nfunc (r *pgRepository) GetServerUsers(serverID uuid.UUID) ([]account.ServerUser, error) {\n\tvar users []account.ServerUser\n\t// Preload User для отображения имен\n\terr := r.db.Preload(\"User\").Where(\"server_id = ?\", serverID).Find(&users).Error\n\treturn users, err\n}\n\nfunc (r *pgRepository) AddUserToServer(serverID, userID uuid.UUID, role account.Role) error {\n\t// Проверка лимита перед добавлением\n\tvar server account.RMSServer\n\tif err := r.db.First(&server, serverID).Error; err != nil {\n\t\treturn err\n\t}\n\n\treturn r.db.Transaction(func(tx *gorm.DB) error {\n\t\t// 1. Сначала проверяем, существует ли пользователь на этом сервере\n\t\tvar existingLink account.ServerUser\n\t\terr := tx.Where(\"server_id = ? AND user_id = ?\", serverID, userID).First(&existingLink).Error\n\n\t\tif err == nil {\n\t\t\t// --- ПОЛЬЗОВАТЕЛЬ УЖЕ ЕСТЬ ---\n\t\t\t// Защита от понижения прав:\n\t\t\t// Если текущая роль OWNER или ADMIN, а мы пытаемся поставить OPERATOR (через инвайт),\n\t\t\t// то игнорируем смену роли, просто делаем активным.\n\t\t\tif (existingLink.Role == account.RoleOwner || existingLink.Role == account.RoleAdmin) && role == account.RoleOperator {\n\t\t\t\trole = existingLink.Role\n\t\t\t}\n\n\t\t\t// Обновляем активность и (возможно) роль\n\t\t\treturn tx.Model(&existingLink).Updates(map[string]interface{}{\n\t\t\t\t\"role\": role,\n\t\t\t\t\"is_active\": true,\n\t\t\t}).Error\n\t\t}\n\n\t\t// --- ПОЛЬЗОВАТЕЛЬ НОВЫЙ ---\n\t\t// Проверяем лимит только для новых\n\t\tvar currentCount int64\n\t\ttx.Model(&account.ServerUser{}).Where(\"server_id = ?\", serverID).Count(¤tCount)\n\t\tif currentCount >= int64(server.MaxUsers) {\n\t\t\treturn fmt.Errorf(\"лимит пользователей (%d) превышен\", server.MaxUsers)\n\t\t}\n\n\t\t// Сбрасываем активность на других серверах\n\t\tif err := tx.Model(&account.ServerUser{}).Where(\"user_id = ?\", userID).Update(\"is_active\", false).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Создаем связь\n\t\tlink := account.ServerUser{\n\t\t\tServerID: serverID,\n\t\t\tUserID: userID,\n\t\t\tRole: role,\n\t\t\tIsActive: true,\n\t\t}\n\t\treturn tx.Create(&link).Error\n\t})\n}\n\nfunc (r *pgRepository) RemoveUserFromServer(serverID, userID uuid.UUID) error {\n\treturn r.db.Where(\"server_id = ? AND user_id = ?\", serverID, userID).Delete(&account.ServerUser{}).Error\n}\n\nfunc (r *pgRepository) IncrementInvoiceCount(serverID uuid.UUID) error {\n\treturn r.db.Model(&account.RMSServer{}).\n\t\tWhere(\"id = ?\", serverID).\n\t\tUpdateColumn(\"invoice_count\", gorm.Expr(\"invoice_count + ?\", 1)).Error\n}\n\n// --- Super Admin Functions ---\n\nfunc (r *pgRepository) GetAllServersSystemWide() ([]account.RMSServer, error) {\n\tvar servers []account.RMSServer\n\t// Загружаем вместе с владельцем для отображения\n\terr := r.db.Order(\"name ASC\").Find(&servers).Error\n\treturn servers, err\n}\n\nfunc (r *pgRepository) TransferOwnership(serverID, newOwnerID uuid.UUID) error {\n\treturn r.db.Transaction(func(tx *gorm.DB) error {\n\t\t// 1. Находим текущего владельца\n\t\tvar currentOwnerLink account.ServerUser\n\t\tif err := tx.Where(\"server_id = ? AND role = ?\", serverID, account.RoleOwner).First(¤tOwnerLink).Error; err != nil {\n\t\t\treturn fmt.Errorf(\"current owner not found: %w\", err)\n\t\t}\n\n\t\t// 2. Проверяем, что новый владелец вообще есть на сервере\n\t\tvar newOwnerLink account.ServerUser\n\t\tif err := tx.Where(\"server_id = ? AND user_id = ?\", serverID, newOwnerID).First(&newOwnerLink).Error; err != nil {\n\t\t\treturn fmt.Errorf(\"target user not found on server: %w\", err)\n\t\t}\n\n\t\t// 3. Понижаем старого владельца до ADMIN\n\t\tif err := tx.Model(¤tOwnerLink).Update(\"role\", account.RoleAdmin).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// 4. Повышаем нового до OWNER\n\t\tif err := tx.Model(&newOwnerLink).Update(\"role\", account.RoleOwner).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// УДАЛЕНО: обновление server.owner_id, так как этого поля нет в модели\n\n\t\treturn nil\n\t})\n}\n\nfunc (r *pgRepository) GetConnectionByID(id uuid.UUID) (*account.ServerUser, error) {\n\tvar link account.ServerUser\n\t// Preload нужны, чтобы показать имена в админке\n\terr := r.db.Preload(\"User\").Preload(\"Server\").Where(\"id = ?\", id).First(&link).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &link, nil\n}\n\nfunc (r *pgRepository) GetServerByURL(rawURL string) (*account.RMSServer, error) {\n\tcleanURL := strings.TrimRight(strings.ToLower(strings.TrimSpace(rawURL)), \"/\")\n\tvar server account.RMSServer\n\terr := r.db.Where(\"base_url = ?\", cleanURL).First(&server).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &server, nil\n}\n\nfunc (r *pgRepository) GetServerByID(id uuid.UUID) (*account.RMSServer, error) {\n\tvar server account.RMSServer\n\terr := r.db.First(&server, id).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &server, nil\n}\n\n// UpdateBalance начисляет пакет или продлевает подписку\nfunc (r *pgRepository) UpdateBalance(serverID uuid.UUID, amountChange int, newPaidUntil *time.Time) error {\n\treturn r.db.Model(&account.RMSServer{}).Where(\"id = ?\", serverID).Updates(map[string]interface{}{\n\t\t\"balance\": gorm.Expr(\"balance + ?\", amountChange),\n\t\t\"paid_until\": newPaidUntil,\n\t}).Error\n}\n\n// DecrementBalance списывает 1 единицу при отправке накладной\nfunc (r *pgRepository) DecrementBalance(serverID uuid.UUID) error {\n\treturn r.db.Model(&account.RMSServer{}).\n\t\tWhere(\"id = ? AND balance > 0\", serverID).\n\t\tUpdateColumn(\"balance\", gorm.Expr(\"balance - ?\", 1)).Error\n}\n", - "internal/infrastructure/repository/billing/postgres.go": "package billing\n\nimport (\n\t\"rmser/internal/domain/billing\"\n\n\t\"github.com/google/uuid\"\n\t\"gorm.io/gorm\"\n)\n\ntype pgRepository struct {\n\tdb *gorm.DB\n}\n\nfunc NewRepository(db *gorm.DB) billing.Repository {\n\treturn &pgRepository{db: db}\n}\n\nfunc (r *pgRepository) CreateOrder(order *billing.Order) error {\n\treturn r.db.Create(order).Error\n}\n\nfunc (r *pgRepository) GetOrder(id uuid.UUID) (*billing.Order, error) {\n\tvar order billing.Order\n\terr := r.db.Where(\"id = ?\", id).First(&order).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &order, nil\n}\n\nfunc (r *pgRepository) UpdateOrderStatus(id uuid.UUID, status billing.OrderStatus, paymentID string) error {\n\tupdates := map[string]interface{}{\n\t\t\"status\": status,\n\t}\n\tif paymentID != \"\" {\n\t\tupdates[\"payment_id\"] = paymentID\n\t}\n\treturn r.db.Model(&billing.Order{}).Where(\"id = ?\", id).Updates(updates).Error\n}\n\nfunc (r *pgRepository) GetActiveOrdersByUser(userID uuid.UUID) ([]billing.Order, error) {\n\tvar orders []billing.Order\n\terr := r.db.Where(\"user_id = ? AND status = ?\", userID, billing.StatusPending).\n\t\tOrder(\"created_at DESC\").\n\t\tFind(&orders).Error\n\treturn orders, err\n}\n", - "internal/infrastructure/repository/catalog/postgres.go": "package catalog\n\nimport (\n\t\"rmser/internal/domain/catalog\"\n\n\t\"github.com/google/uuid\"\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/clause\"\n)\n\ntype pgRepository struct {\n\tdb *gorm.DB\n}\n\nfunc NewRepository(db *gorm.DB) catalog.Repository {\n\treturn &pgRepository{db: db}\n}\n\n// --- Запись (Save) ---\n// При сохранении мы предполагаем, что serverID уже проставлен в Entity в слое Service.\n// Но для надежности можно передавать serverID в метод Save, однако Service должен это контролировать.\n// Оставим контракт Save(products []Product), где внутри products уже заполнен RMSServerID.\n\nfunc (r *pgRepository) SaveMeasureUnits(units []catalog.MeasureUnit) error {\n\tif len(units) == 0 {\n\t\treturn nil\n\t}\n\treturn r.db.Clauses(clause.OnConflict{\n\t\tColumns: []clause.Column{{Name: \"id\"}}, // ID глобально уникален (UUID), конфликтов между серверами не будет\n\t\tUpdateAll: true,\n\t}).CreateInBatches(units, 100).Error\n}\n\nfunc (r *pgRepository) SaveProducts(products []catalog.Product) error {\n\tsorted := sortProductsByHierarchy(products)\n\treturn r.db.Transaction(func(tx *gorm.DB) error {\n\t\t// 1. Продукты\n\t\tif err := tx.Omit(\"Containers\").Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tUpdateAll: true,\n\t\t}).CreateInBatches(sorted, 100).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// 2. Контейнеры\n\t\tvar allContainers []catalog.ProductContainer\n\t\tfor _, p := range products {\n\t\t\tallContainers = append(allContainers, p.Containers...)\n\t\t}\n\n\t\tif len(allContainers) > 0 {\n\t\t\tif err := tx.Clauses(clause.OnConflict{\n\t\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\t\tUpdateAll: true,\n\t\t\t}).CreateInBatches(allContainers, 100).Error; err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc (r *pgRepository) SaveContainer(container catalog.ProductContainer) error {\n\treturn r.db.Clauses(clause.OnConflict{\n\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\tUpdateAll: true,\n\t}).Create(&container).Error\n}\n\nfunc (r *pgRepository) SaveStores(stores []catalog.Store) error {\n\tif len(stores) == 0 {\n\t\treturn nil\n\t}\n\treturn r.db.Clauses(clause.OnConflict{\n\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\tUpdateAll: true,\n\t}).CreateInBatches(stores, 100).Error\n}\n\n// --- Чтение (Read) с фильтрацией по ServerID ---\n\nfunc (r *pgRepository) GetAll() ([]catalog.Product, error) {\n\t// Этот метод был legacy и грузил всё. Теперь он опасен без serverID.\n\t// Оставляем заглушку или удаляем. Лучше удалить из интерфейса, но пока вернем пустой список\n\t// чтобы не ломать сборку, пока не почистим вызовы.\n\treturn nil, nil\n}\n\nfunc (r *pgRepository) GetActiveGoods(serverID uuid.UUID, rootGroupID *uuid.UUID) ([]catalog.Product, error) {\n\tvar products []catalog.Product\n\tdb := r.db.Preload(\"MainUnit\").Preload(\"Containers\").\n\t\tWhere(\"rms_server_id = ? AND is_deleted = ? AND type IN ?\", serverID, false, []string{\"GOODS\"})\n\n\tif rootGroupID != nil && *rootGroupID != uuid.Nil {\n\t\t// Используем Recursive CTE для поиска всех дочерних элементов папки\n\t\tsubQuery := r.db.Raw(`\n\t\t\tWITH RECURSIVE subnodes AS (\n\t\t\t\tSELECT id FROM products WHERE id = ?\n\t\t\t\tUNION ALL\n\t\t\t\tSELECT p.id FROM products p INNER JOIN subnodes s ON p.parent_id = s.id\n\t\t\t)\n\t\t\tSELECT id FROM subnodes\n\t\t`, rootGroupID)\n\t\tdb = db.Where(\"id IN (?)\", subQuery)\n\t}\n\n\terr := db.Order(\"name ASC\").Find(&products).Error\n\treturn products, err\n}\n\nfunc (r *pgRepository) GetActiveStores(serverID uuid.UUID) ([]catalog.Store, error) {\n\tvar stores []catalog.Store\n\terr := r.db.Where(\"rms_server_id = ? AND is_deleted = ?\", serverID, false).Order(\"name ASC\").Find(&stores).Error\n\treturn stores, err\n}\n\nfunc (r *pgRepository) Search(serverID uuid.UUID, query string, rootGroupID *uuid.UUID) ([]catalog.Product, error) {\n\tvar products []catalog.Product\n\tq := \"%\" + query + \"%\"\n\n\tdb := r.db.Preload(\"MainUnit\").Preload(\"Containers\").\n\t\tWhere(\"rms_server_id = ? AND is_deleted = ? AND type = ?\", serverID, false, \"GOODS\").\n\t\tWhere(\"(name ILIKE ? OR code ILIKE ? OR num ILIKE ?)\", q, q, q)\n\n\tif rootGroupID != nil && *rootGroupID != uuid.Nil {\n\t\tsubQuery := r.db.Raw(`\n\t\t\tWITH RECURSIVE subnodes AS (\n\t\t\t\tSELECT id FROM products WHERE id = ?\n\t\t\t\tUNION ALL\n\t\t\t\tSELECT p.id FROM products p INNER JOIN subnodes s ON p.parent_id = s.id\n\t\t\t)\n\t\t\tSELECT id FROM subnodes\n\t\t`, rootGroupID)\n\t\tdb = db.Where(\"id IN (?)\", subQuery)\n\t}\n\n\terr := db.Order(\"name ASC\").Limit(20).Find(&products).Error\n\treturn products, err\n}\n\n// sortProductsByHierarchy - вспомогательная функция, оставляем как есть (копипаст из старого файла)\nfunc sortProductsByHierarchy(products []catalog.Product) []catalog.Product {\n\tif len(products) == 0 {\n\t\treturn products\n\t}\n\tchildrenMap := make(map[uuid.UUID][]catalog.Product)\n\tvar roots []catalog.Product\n\tallIDs := make(map[uuid.UUID]struct{}, len(products))\n\n\tfor _, p := range products {\n\t\tallIDs[p.ID] = struct{}{}\n\t}\n\n\tfor _, p := range products {\n\t\tif p.ParentID == nil {\n\t\t\troots = append(roots, p)\n\t\t} else {\n\t\t\tif _, exists := allIDs[*p.ParentID]; exists {\n\t\t\t\tchildrenMap[*p.ParentID] = append(childrenMap[*p.ParentID], p)\n\t\t\t} else {\n\t\t\t\troots = append(roots, p)\n\t\t\t}\n\t\t}\n\t}\n\n\tresult := make([]catalog.Product, 0, len(products))\n\tqueue := roots\n\tfor len(queue) > 0 {\n\t\tcurrent := queue[0]\n\t\tqueue = queue[1:]\n\t\tresult = append(result, current)\n\t\tif children, ok := childrenMap[current.ID]; ok {\n\t\t\tqueue = append(queue, children...)\n\t\t\tdelete(childrenMap, current.ID)\n\t\t}\n\t}\n\tfor _, remaining := range childrenMap {\n\t\tresult = append(result, remaining...)\n\t}\n\treturn result\n}\n\nfunc (r *pgRepository) CountGoods(serverID uuid.UUID) (int64, error) {\n\tvar count int64\n\terr := r.db.Model(&catalog.Product{}).\n\t\tWhere(\"rms_server_id = ? AND type IN ? AND is_deleted = ?\", serverID, []string{\"GOODS\"}, false).\n\t\tCount(&count).Error\n\treturn count, err\n}\n\nfunc (r *pgRepository) CountStores(serverID uuid.UUID) (int64, error) {\n\tvar count int64\n\terr := r.db.Model(&catalog.Store{}).\n\t\tWhere(\"rms_server_id = ? AND is_deleted = ?\", serverID, false).\n\t\tCount(&count).Error\n\treturn count, err\n}\n\nfunc (r *pgRepository) GetGroups(serverID uuid.UUID) ([]catalog.Product, error) {\n\tvar groups []catalog.Product\n\t// iiko присылает группы с типом \"GROUP\"\n\terr := r.db.Where(\"rms_server_id = ? AND type = ? AND is_deleted = ?\", serverID, \"GROUP\", false).\n\t\tOrder(\"name ASC\").\n\t\tFind(&groups).Error\n\treturn groups, err\n}\n", - "internal/infrastructure/repository/drafts/postgres.go": "package drafts\n\nimport (\n\t\"rmser/internal/domain/drafts\"\n\n\t\"github.com/google/uuid\"\n\t\"gorm.io/gorm\"\n)\n\ntype pgRepository struct {\n\tdb *gorm.DB\n}\n\nfunc NewRepository(db *gorm.DB) drafts.Repository {\n\treturn &pgRepository{db: db}\n}\n\nfunc (r *pgRepository) Create(draft *drafts.DraftInvoice) error {\n\treturn r.db.Create(draft).Error\n}\n\nfunc (r *pgRepository) GetByID(id uuid.UUID) (*drafts.DraftInvoice, error) {\n\tvar draft drafts.DraftInvoice\n\terr := r.db.\n\t\tPreload(\"Items\", func(db *gorm.DB) *gorm.DB {\n\t\t\treturn db.Order(\"draft_invoice_items.raw_name ASC\")\n\t\t}).\n\t\tPreload(\"Items.Product\").\n\t\tPreload(\"Items.Product.MainUnit\").\n\t\tPreload(\"Items.Container\").\n\t\tWhere(\"id = ?\", id).\n\t\tFirst(&draft).Error\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &draft, nil\n}\n\nfunc (r *pgRepository) GetByRMSInvoiceID(rmsInvoiceID uuid.UUID) (*drafts.DraftInvoice, error) {\n\tvar draft drafts.DraftInvoice\n\terr := r.db.\n\t\tWhere(\"rms_invoice_id = ?\", rmsInvoiceID).\n\t\tFirst(&draft).Error\n\tif err != nil {\n\t\tif err == gorm.ErrRecordNotFound {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\treturn &draft, nil\n}\n\nfunc (r *pgRepository) Update(draft *drafts.DraftInvoice) error {\n\treturn r.db.Model(draft).Updates(map[string]interface{}{\n\t\t\"status\": draft.Status,\n\t\t\"document_number\": draft.DocumentNumber,\n\t\t\"incoming_document_number\": draft.IncomingDocumentNumber,\n\t\t\"date_incoming\": draft.DateIncoming,\n\t\t\"supplier_id\": draft.SupplierID,\n\t\t\"store_id\": draft.StoreID,\n\t\t\"comment\": draft.Comment,\n\t\t\"rms_invoice_id\": draft.RMSInvoiceID,\n\t\t\"rms_server_id\": draft.RMSServerID,\n\t\t\"updated_at\": gorm.Expr(\"NOW()\"),\n\t}).Error\n}\n\nfunc (r *pgRepository) CreateItems(items []drafts.DraftInvoiceItem) error {\n\tif len(items) == 0 {\n\t\treturn nil\n\t}\n\treturn r.db.CreateInBatches(items, 100).Error\n}\n\nfunc (r *pgRepository) CreateItem(item *drafts.DraftInvoiceItem) error {\n\treturn r.db.Create(item).Error\n}\n\nfunc (r *pgRepository) DeleteItem(itemID uuid.UUID) error {\n\treturn r.db.Delete(&drafts.DraftInvoiceItem{}, itemID).Error\n}\n\n// GetItemByID - новый метод\nfunc (r *pgRepository) GetItemByID(itemID uuid.UUID) (*drafts.DraftInvoiceItem, error) {\n\tvar item drafts.DraftInvoiceItem\n\terr := r.db.Where(\"id = ?\", itemID).First(&item).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\n// UpdateItem - обновленный метод, принимает map\nfunc (r *pgRepository) UpdateItem(itemID uuid.UUID, updates map[string]interface{}) error {\n\treturn r.db.Model(&drafts.DraftInvoiceItem{}).\n\t\tWhere(\"id = ?\", itemID).\n\t\tUpdates(updates).Error\n}\n\nfunc (r *pgRepository) Delete(id uuid.UUID) error {\n\treturn r.db.Delete(&drafts.DraftInvoice{}, id).Error\n}\n\nfunc (r *pgRepository) GetActive(serverID uuid.UUID) ([]drafts.DraftInvoice, error) {\n\tvar list []drafts.DraftInvoice\n\n\tactiveStatuses := []string{\n\t\tdrafts.StatusProcessing,\n\t\tdrafts.StatusReadyToVerify,\n\t\tdrafts.StatusError,\n\t\tdrafts.StatusCanceled,\n\t}\n\n\terr := r.db.\n\t\tPreload(\"Items\").\n\t\tPreload(\"Store\").\n\t\tWhere(\"rms_server_id = ? AND status IN ?\", serverID, activeStatuses).\n\t\tOrder(\"created_at DESC\").\n\t\tFind(&list).Error\n\n\treturn list, err\n}\n\nfunc (r *pgRepository) GetRMSInvoiceIDToPhotoURLMap(serverID uuid.UUID) (map[uuid.UUID]string, error) {\n\tvar draftsList []drafts.DraftInvoice\n\terr := r.db.\n\t\tSelect(\"rms_invoice_id\", \"sender_photo_url\").\n\t\tWhere(\"rms_server_id = ? AND rms_invoice_id IS NOT NULL\", serverID).\n\t\tFind(&draftsList).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tresult := make(map[uuid.UUID]string)\n\tfor _, d := range draftsList {\n\t\tif d.RMSInvoiceID != nil {\n\t\t\tresult[*d.RMSInvoiceID] = d.SenderPhotoURL\n\t\t}\n\t}\n\treturn result, nil\n}\n", - "internal/infrastructure/repository/invoices/postgres.go": "package invoices\n\nimport (\n\t\"time\"\n\n\t\"rmser/internal/domain/invoices\"\n\n\t\"github.com/google/uuid\"\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/clause\"\n)\n\ntype pgRepository struct {\n\tdb *gorm.DB\n}\n\nfunc NewRepository(db *gorm.DB) invoices.Repository {\n\treturn &pgRepository{db: db}\n}\n\nfunc (r *pgRepository) GetByID(id uuid.UUID) (*invoices.Invoice, error) {\n\tvar inv invoices.Invoice\n\terr := r.db.\n\t\tPreload(\"Items\").\n\t\tPreload(\"Items.Product\").\n\t\tWhere(\"id = ?\", id).\n\t\tFirst(&inv).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &inv, nil\n}\n\nfunc (r *pgRepository) GetLastInvoiceDate(serverID uuid.UUID) (*time.Time, error) {\n\tvar inv invoices.Invoice\n\t// Ищем последнюю накладную только для этого сервера\n\terr := r.db.Where(\"rms_server_id = ?\", serverID).Order(\"date_incoming DESC\").First(&inv).Error\n\tif err != nil {\n\t\tif err == gorm.ErrRecordNotFound {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\treturn &inv.DateIncoming, nil\n}\n\nfunc (r *pgRepository) GetByPeriod(serverID uuid.UUID, from, to time.Time) ([]invoices.Invoice, error) {\n\tvar list []invoices.Invoice\n\terr := r.db.\n\t\tPreload(\"Items\").\n\t\tPreload(\"Items.Product\").\n\t\tWhere(\"rms_server_id = ? AND date_incoming BETWEEN ? AND ? AND status != ?\", serverID, from, to, \"DELETED\").\n\t\tOrder(\"date_incoming DESC\").\n\t\tFind(&list).Error\n\treturn list, err\n}\n\nfunc (r *pgRepository) SaveInvoices(list []invoices.Invoice) error {\n\treturn r.db.Transaction(func(tx *gorm.DB) error {\n\t\tfor _, inv := range list {\n\t\t\tif err := tx.Omit(\"Items\").Clauses(clause.OnConflict{\n\t\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\t\tUpdateAll: true,\n\t\t\t}).Create(&inv).Error; err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\t// Удаляем старые Items для этой накладной\n\t\t\tif err := tx.Where(\"invoice_id = ?\", inv.ID).Delete(&invoices.InvoiceItem{}).Error; err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif len(inv.Items) > 0 {\n\t\t\t\tif err := tx.Create(&inv.Items).Error; err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc (r *pgRepository) CountRecent(serverID uuid.UUID, days int) (int64, error) {\n\tvar count int64\n\tdateFrom := time.Now().AddDate(0, 0, -days)\n\n\terr := r.db.Model(&invoices.Invoice{}).\n\t\tWhere(\"rms_server_id = ? AND date_incoming >= ?\", serverID, dateFrom).\n\t\tCount(&count).Error\n\treturn count, err\n}\n", - "internal/infrastructure/repository/ocr/postgres.go": "package ocr\n\nimport (\n\t\"strings\"\n\t\"time\"\n\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/clause\"\n\n\t\"rmser/internal/domain/ocr\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/shopspring/decimal\"\n)\n\ntype pgRepository struct {\n\tdb *gorm.DB\n}\n\nfunc NewRepository(db *gorm.DB) ocr.Repository {\n\treturn &pgRepository{db: db}\n}\n\nfunc (r *pgRepository) SaveMatch(serverID uuid.UUID, rawName string, productID uuid.UUID, quantity decimal.Decimal, containerID *uuid.UUID) error {\n\tnormalized := strings.ToLower(strings.TrimSpace(rawName))\n\n\tmatch := ocr.ProductMatch{\n\t\tRMSServerID: serverID,\n\t\tRawName: normalized,\n\t\tProductID: productID,\n\t\tQuantity: quantity,\n\t\tContainerID: containerID,\n\t}\n\n\treturn r.db.Transaction(func(tx *gorm.DB) error {\n\t\t// Используем OnConflict по составному индексу (raw_name, rms_server_id)\n\t\t// Но GORM может потребовать названия ограничения.\n\t\t// Проще сделать через Where().Assign().FirstOrCreate() или явно указать Columns если индекс есть.\n\t\t// В Entity мы указали `uniqueIndex:idx_raw_server`.\n\n\t\tif err := tx.Clauses(clause.OnConflict{\n\t\t\t// Указываем оба поля, входящие в unique index\n\t\t\tColumns: []clause.Column{{Name: \"raw_name\"}, {Name: \"rms_server_id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"product_id\", \"quantity\", \"container_id\", \"updated_at\"}),\n\t\t}).Create(&match).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Удаляем из Unmatched для этого сервера\n\t\tif err := tx.Where(\"rms_server_id = ? AND raw_name = ?\", serverID, normalized).Delete(&ocr.UnmatchedItem{}).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc (r *pgRepository) DeleteMatch(serverID uuid.UUID, rawName string) error {\n\tnormalized := strings.ToLower(strings.TrimSpace(rawName))\n\treturn r.db.Where(\"rms_server_id = ? AND raw_name = ?\", serverID, normalized).Delete(&ocr.ProductMatch{}).Error\n}\n\nfunc (r *pgRepository) FindMatch(serverID uuid.UUID, rawName string) (*ocr.ProductMatch, error) {\n\tnormalized := strings.ToLower(strings.TrimSpace(rawName))\n\tvar match ocr.ProductMatch\n\n\terr := r.db.Preload(\"Container\").\n\t\tWhere(\"rms_server_id = ? AND raw_name = ?\", serverID, normalized).\n\t\tFirst(&match).Error\n\n\tif err != nil {\n\t\tif err == gorm.ErrRecordNotFound {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\treturn &match, nil\n}\n\nfunc (r *pgRepository) GetAllMatches(serverID uuid.UUID) ([]ocr.ProductMatch, error) {\n\tvar matches []ocr.ProductMatch\n\terr := r.db.\n\t\tPreload(\"Product\").\n\t\tPreload(\"Product.MainUnit\").\n\t\tPreload(\"Product.Containers\").\n\t\tPreload(\"Container\").\n\t\tWhere(\"rms_server_id = ?\", serverID).\n\t\tOrder(\"updated_at DESC\").\n\t\tFind(&matches).Error\n\treturn matches, err\n}\n\nfunc (r *pgRepository) UpsertUnmatched(serverID uuid.UUID, rawName string) error {\n\tnormalized := strings.ToLower(strings.TrimSpace(rawName))\n\tif normalized == \"\" {\n\t\treturn nil\n\t}\n\n\titem := ocr.UnmatchedItem{\n\t\tRMSServerID: serverID,\n\t\tRawName: normalized,\n\t\tCount: 1,\n\t\tLastSeen: time.Now(),\n\t}\n\n\treturn r.db.Clauses(clause.OnConflict{\n\t\tColumns: []clause.Column{{Name: \"raw_name\"}, {Name: \"rms_server_id\"}},\n\t\tDoUpdates: clause.Assignments(map[string]interface{}{\n\t\t\t\"count\": gorm.Expr(\"unmatched_items.count + 1\"),\n\t\t\t\"last_seen\": time.Now(),\n\t\t}),\n\t}).Create(&item).Error\n}\n\nfunc (r *pgRepository) GetTopUnmatched(serverID uuid.UUID, limit int) ([]ocr.UnmatchedItem, error) {\n\tvar items []ocr.UnmatchedItem\n\terr := r.db.Where(\"rms_server_id = ?\", serverID).\n\t\tOrder(\"count DESC, last_seen DESC\").\n\t\tLimit(limit).\n\t\tFind(&items).Error\n\treturn items, err\n}\n\nfunc (r *pgRepository) DeleteUnmatched(serverID uuid.UUID, rawName string) error {\n\tnormalized := strings.ToLower(strings.TrimSpace(rawName))\n\treturn r.db.Where(\"rms_server_id = ? AND raw_name = ?\", serverID, normalized).Delete(&ocr.UnmatchedItem{}).Error\n}\n", - "internal/infrastructure/repository/operations/postgres.go": "package operations\n\nimport (\n\t\"time\"\n\n\t\"rmser/internal/domain/operations\"\n\n\t\"github.com/google/uuid\"\n\n\t\"gorm.io/gorm\"\n)\n\ntype pgRepository struct {\n\tdb *gorm.DB\n}\n\nfunc NewRepository(db *gorm.DB) operations.Repository {\n\treturn &pgRepository{db: db}\n}\n\nfunc (r *pgRepository) SaveOperations(ops []operations.StoreOperation, serverID uuid.UUID, opType operations.OperationType, dateFrom, dateTo time.Time) error {\n\treturn r.db.Transaction(func(tx *gorm.DB) error {\n\t\t// Удаляем старые записи этого типа, но ТОЛЬКО для конкретного сервера\n\t\tif err := tx.Where(\"rms_server_id = ? AND op_type = ? AND period_from >= ? AND period_to <= ?\",\n\t\t\tserverID, opType, dateFrom, dateTo).\n\t\t\tDelete(&operations.StoreOperation{}).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif len(ops) > 0 {\n\t\t\tif err := tx.CreateInBatches(ops, 500).Error; err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n", - "internal/infrastructure/repository/photos/postgres.go": "package photos\n\nimport (\n\t\"rmser/internal/domain/photos\"\n\n\t\"github.com/google/uuid\"\n\t\"gorm.io/gorm\"\n)\n\ntype pgRepository struct {\n\tdb *gorm.DB\n}\n\nfunc NewRepository(db *gorm.DB) photos.Repository {\n\treturn &pgRepository{db: db}\n}\n\nfunc (r *pgRepository) Create(photo *photos.ReceiptPhoto) error {\n\treturn r.db.Create(photo).Error\n}\n\nfunc (r *pgRepository) GetByID(id uuid.UUID) (*photos.ReceiptPhoto, error) {\n\tvar photo photos.ReceiptPhoto\n\terr := r.db.First(&photo, id).Error\n\treturn &photo, err\n}\n\nfunc (r *pgRepository) GetByServerID(serverID uuid.UUID, page, limit int) ([]photos.ReceiptPhoto, int64, error) {\n\tvar items []photos.ReceiptPhoto\n\tvar total int64\n\n\toffset := (page - 1) * limit\n\n\terr := r.db.Model(&photos.ReceiptPhoto{}).\n\t\tWhere(\"rms_server_id = ?\", serverID).\n\t\tCount(&total).Error\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\terr = r.db.Where(\"rms_server_id = ?\", serverID).\n\t\tOrder(\"created_at DESC\").\n\t\tOffset(offset).\n\t\tLimit(limit).\n\t\tFind(&items).Error\n\n\treturn items, total, err\n}\n\nfunc (r *pgRepository) UpdateDraftLink(photoID uuid.UUID, draftID *uuid.UUID) error {\n\treturn r.db.Model(&photos.ReceiptPhoto{}).\n\t\tWhere(\"id = ?\", photoID).\n\t\tUpdate(\"draft_id\", draftID).Error\n}\n\nfunc (r *pgRepository) UpdateInvoiceLink(photoID uuid.UUID, invoiceID *uuid.UUID) error {\n\treturn r.db.Model(&photos.ReceiptPhoto{}).\n\t\tWhere(\"id = ?\", photoID).\n\t\tUpdate(\"invoice_id\", invoiceID).Error\n}\n\nfunc (r *pgRepository) ClearDraftLinkByDraftID(draftID uuid.UUID) error {\n\treturn r.db.Model(&photos.ReceiptPhoto{}).\n\t\tWhere(\"draft_id = ?\", draftID).\n\t\tUpdate(\"draft_id\", nil).Error\n}\n\nfunc (r *pgRepository) Delete(id uuid.UUID) error {\n\treturn r.db.Delete(&photos.ReceiptPhoto{}, id).Error\n}\n", - "internal/infrastructure/repository/recipes/postgres.go": "package recipes\n\nimport (\n\t\"rmser/internal/domain/recipes\"\n\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/clause\"\n)\n\ntype pgRepository struct {\n\tdb *gorm.DB\n}\n\nfunc NewRepository(db *gorm.DB) recipes.Repository {\n\treturn &pgRepository{db: db}\n}\n\n// Техкарты сохраняются пачкой, serverID внутри структуры\nfunc (r *pgRepository) SaveRecipes(list []recipes.Recipe) error {\n\treturn r.db.Transaction(func(tx *gorm.DB) error {\n\t\tfor _, recipe := range list {\n\t\t\tif err := tx.Omit(\"Items\").Clauses(clause.OnConflict{\n\t\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\t\tUpdateAll: true,\n\t\t\t}).Create(&recipe).Error; err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif err := tx.Where(\"recipe_id = ?\", recipe.ID).Delete(&recipes.RecipeItem{}).Error; err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif len(recipe.Items) > 0 {\n\t\t\t\tif err := tx.Create(&recipe.Items).Error; err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n", - "internal/infrastructure/repository/recommendations/postgres.go": "package recommendations\n\nimport (\n\t\"fmt\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"rmser/internal/domain/operations\"\n\t\"rmser/internal/domain/recommendations\"\n\n\t\"gorm.io/gorm\"\n)\n\ntype pgRepository struct {\n\tdb *gorm.DB\n}\n\nfunc NewRepository(db *gorm.DB) recommendations.Repository {\n\treturn &pgRepository{db: db}\n}\n\n// --- Методы Хранения ---\n\nfunc (r *pgRepository) SaveAll(items []recommendations.Recommendation) error {\n\treturn r.db.Transaction(func(tx *gorm.DB) error {\n\t\tif err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Delete(&recommendations.Recommendation{}).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif len(items) > 0 {\n\t\t\tif err := tx.CreateInBatches(items, 100).Error; err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc (r *pgRepository) GetAll() ([]recommendations.Recommendation, error) {\n\tvar items []recommendations.Recommendation\n\terr := r.db.Find(&items).Error\n\treturn items, err\n}\n\n// --- Методы Аналитики ---\n\n// 1. Товары (GOODS/PREPARED), не используемые в техкартах\nfunc (r *pgRepository) FindUnusedGoods() ([]recommendations.Recommendation, error) {\n\tvar results []recommendations.Recommendation\n\n\tquery := `\n\t\tSELECT \n\t\t\tp.id as product_id, \n\t\t\tp.name as product_name,\n\t\t\t'Товар не используется ни в одной техкарте' as reason,\n\t\t\t? as type\n\t\tFROM products p\n\t\tWHERE p.type IN ('GOODS', 'PREPARED') \n\t\t AND p.is_deleted = false -- Проверка на удаление\n\t\t AND p.id NOT IN (\n\t\t SELECT DISTINCT product_id FROM recipe_items\n\t\t )\n\t\t AND p.id NOT IN (\n\t\t SELECT DISTINCT product_id FROM recipes\n\t\t )\n\t\tORDER BY p.name ASC\n\t`\n\n\tif err := r.db.Raw(query, recommendations.TypeUnused).Scan(&results).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn results, nil\n}\n\n// 2. Закупается, но нет в техкартах\nfunc (r *pgRepository) FindPurchasedButUnused(days int) ([]recommendations.Recommendation, error) {\n\tvar results []recommendations.Recommendation\n\tdateFrom := time.Now().AddDate(0, 0, -days)\n\n\tquery := `\n\t\tSELECT DISTINCT\n\t\t\tp.id as product_id,\n\t\t\tp.name as product_name,\n\t\t\t'Товар активно закупается, но не включен ни в одну техкарту' as reason,\n\t\t\t? as type\n\t\tFROM store_operations so\n\t\tJOIN products p ON so.product_id = p.id\n\t\tWHERE \n\t\t\tso.op_type = ? \n\t\t\tAND so.period_from >= ?\n\t\t\tAND p.is_deleted = false -- Проверка на удаление\n\t\t\tAND p.id NOT IN ( \n\t\t\t\tSELECT DISTINCT product_id FROM recipe_items\n\t\t\t)\n\t\tORDER BY p.name ASC\n\t`\n\n\tif err := r.db.Raw(query, recommendations.TypePurchasedButUnused, operations.OpTypePurchase, dateFrom).Scan(&results).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn results, nil\n}\n\n// 3. Ингредиенты в актуальных техкартах без закупок\nfunc (r *pgRepository) FindNoIncomingIngredients(days int) ([]recommendations.Recommendation, error) {\n\tvar results []recommendations.Recommendation\n\tdateFrom := time.Now().AddDate(0, 0, -days)\n\n\tquery := `\n\t\tSELECT\n\t\t\tp.id as product_id,\n\t\t\tp.name as product_name,\n\t\t\t'Нет закупок (' || ? || ' дн). Входит в: ' || STRING_AGG(DISTINCT parent.name, ', ') as reason,\n\t\t\t? as type\n\t\tFROM recipe_items ri\n\t\tJOIN recipes r ON ri.recipe_id = r.id\n\t\tJOIN products p ON ri.product_id = p.id\n\t\tJOIN products parent ON r.product_id = parent.id\n\t\tWHERE \n\t\t\t(r.date_to IS NULL OR r.date_to >= CURRENT_DATE)\n\t\t\tAND p.type = 'GOODS'\n\t\t\tAND p.is_deleted = false -- Сам ингредиент не удален\n\t\t\tAND parent.is_deleted = false -- Блюдо, в которое он входит, не удалено\n\t\t\tAND p.id NOT IN (\n\t\t\t\tSELECT product_id \n\t\t\t\tFROM store_operations \n\t\t\t\tWHERE op_type = ? \n\t\t\t\t AND period_from >= ?\n\t\t\t)\n\t\tGROUP BY p.id, p.name\n\t\tORDER BY p.name ASC\n\t`\n\n\tif err := r.db.Raw(query, strconv.Itoa(days), recommendations.TypeNoIncoming, operations.OpTypePurchase, dateFrom).Scan(&results).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn results, nil\n}\n\n// 4. Товары, которые закупаем, но не расходуем (\"Висяки\")\nfunc (r *pgRepository) FindStaleGoods(days int) ([]recommendations.Recommendation, error) {\n\tvar results []recommendations.Recommendation\n\tdateFrom := time.Now().AddDate(0, 0, -days)\n\n\tquery := `\n\t\tSELECT DISTINCT\n\t\t\tp.id as product_id,\n\t\t\tp.name as product_name,\n\t\t\t? as reason,\n\t\t\t? as type\n\t\tFROM store_operations so\n\t\tJOIN products p ON so.product_id = p.id\n\t\tWHERE \n\t\t\tso.op_type = ? \n\t\t\tAND so.period_from >= ?\n\t\t\tAND p.is_deleted = false -- Проверка на удаление\n\t\t\tAND p.id NOT IN ( \n\t\t\t\tSELECT product_id \n\t\t\t\tFROM store_operations \n\t\t\t\tWHERE op_type = ? \n\t\t\t\t AND period_from >= ?\n\t\t\t)\n\t\tORDER BY p.name ASC\n\t`\n\n\treason := fmt.Sprintf(\"Были закупки, но нет расхода за %d дн.\", days)\n\n\tif err := r.db.Raw(query, reason, recommendations.TypeStale, operations.OpTypePurchase, dateFrom, operations.OpTypeUsage, dateFrom).\n\t\tScan(&results).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn results, nil\n}\n\n// 5. Блюдо используется в техкарте другого блюда\nfunc (r *pgRepository) FindDishesInRecipes() ([]recommendations.Recommendation, error) {\n\tvar results []recommendations.Recommendation\n\n\tquery := `\n\t\tSELECT DISTINCT\n\t\t\tchild.id as product_id,\n\t\t\tchild.name as product_name,\n\t\t\t'Является Блюдом (DISH), но указан ингредиентом в: ' || parent.name as reason,\n\t\t\t? as type\n\t\tFROM recipe_items ri\n\t\tJOIN products child ON ri.product_id = child.id\n\t\tJOIN recipes r ON ri.recipe_id = r.id\n\t\tJOIN products parent ON r.product_id = parent.id\n\t\tWHERE \n\t\t\tchild.type = 'DISH'\n\t\t\tAND child.is_deleted = false -- Вложенное блюдо не удалено\n\t\t\tAND parent.is_deleted = false -- Родительское блюдо не удалено\n\t\t\tAND (r.date_to IS NULL OR r.date_to >= CURRENT_DATE)\n\t\tORDER BY child.name ASC\n\t`\n\n\tif err := r.db.Raw(query, recommendations.TypeDishInRecipe).Scan(&results).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn results, nil\n}\n\n// 6. Есть расход (Usage), но нет прихода (Purchase)\nfunc (r *pgRepository) FindUsageWithoutPurchase(days int) ([]recommendations.Recommendation, error) {\n\tvar results []recommendations.Recommendation\n\tdateFrom := time.Now().AddDate(0, 0, -days)\n\n\tquery := `\n\t\tSELECT DISTINCT\n\t\t\tp.id as product_id,\n\t\t\tp.name as product_name,\n\t\t\t? as reason,\n\t\t\t? as type\n\t\tFROM store_operations so\n\t\tJOIN products p ON so.product_id = p.id\n\t\tWHERE \n\t\t\tso.op_type = ? -- Есть расход (продажа/списание)\n\t\t\tAND so.period_from >= ?\n\t\t\tAND p.type = 'GOODS' -- Только для товаров\n\t\t\tAND p.is_deleted = false -- Товар жив\n\t\t\tAND p.id NOT IN ( -- Но не было закупок\n\t\t\t\tSELECT product_id \n\t\t\t\tFROM store_operations \n\t\t\t\tWHERE op_type = ? \n\t\t\t\t AND period_from >= ?\n\t\t\t)\n\t\tORDER BY p.name ASC\n\t`\n\n\treason := fmt.Sprintf(\"Товар расходуется (продажи/списания), но не закупался последние %d дн.\", days)\n\n\t// Аргументы: reason, type, OpUsage, date, OpPurchase, date\n\tif err := r.db.Raw(query,\n\t\treason,\n\t\trecommendations.TypeUsageNoIncoming,\n\t\toperations.OpTypeUsage,\n\t\tdateFrom,\n\t\toperations.OpTypePurchase,\n\t\tdateFrom,\n\t).Scan(&results).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn results, nil\n}\n", - "internal/infrastructure/repository/suppliers/postgres.go": "package suppliers\n\nimport (\n\t\"rmser/internal/domain/suppliers\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/clause\"\n)\n\ntype pgRepository struct {\n\tdb *gorm.DB\n}\n\nfunc NewRepository(db *gorm.DB) suppliers.Repository {\n\treturn &pgRepository{db: db}\n}\n\nfunc (r *pgRepository) SaveBatch(list []suppliers.Supplier) error {\n\tif len(list) == 0 {\n\t\treturn nil\n\t}\n\treturn r.db.Clauses(clause.OnConflict{\n\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\tUpdateAll: true,\n\t}).CreateInBatches(list, 100).Error\n}\n\n// GetRankedByUsage возвращает поставщиков для конкретного сервера,\n// отсортированных по количеству накладных за последние N дней.\nfunc (r *pgRepository) GetRankedByUsage(serverID uuid.UUID, daysLookBack int) ([]suppliers.Supplier, error) {\n\tvar result []suppliers.Supplier\n\n\tdateThreshold := time.Now().AddDate(0, 0, -daysLookBack)\n\n\t// SQL: Join Suppliers с Invoices, Group By Supplier, Order By Count DESC\n\t// Учитываем только активных поставщиков и накладные этого же сервера (через supplier_id + default_store_id косвенно,\n\t// но лучше явно фильтровать suppliers по rms_server_id).\n\t// *Примечание:* Invoices пока не имеют поля rms_server_id явно в старой схеме,\n\t// но мы должны фильтровать Suppliers по serverID.\n\n\terr := r.db.Table(\"suppliers\").\n\t\tSelect(\"suppliers.*, COUNT(invoices.id) as usage_count\").\n\t\tJoins(\"LEFT JOIN invoices ON invoices.supplier_id = suppliers.id AND invoices.date_incoming >= ?\", dateThreshold).\n\t\tWhere(\"suppliers.rms_server_id = ? AND suppliers.is_deleted = ?\", serverID, false).\n\t\tGroup(\"suppliers.id\").\n\t\tOrder(\"usage_count DESC, suppliers.name ASC\").\n\t\tFind(&result).Error\n\n\treturn result, err\n}\n\nfunc (r *pgRepository) GetByID(id uuid.UUID) (*suppliers.Supplier, error) {\n\tvar supplier suppliers.Supplier\n\terr := r.db.Where(\"id = ? AND is_deleted = ?\", id, false).First(&supplier).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &supplier, nil\n}\n\nfunc (r *pgRepository) Count(serverID uuid.UUID) (int64, error) {\n\tvar count int64\n\terr := r.db.Model(&suppliers.Supplier{}).\n\t\tWhere(\"rms_server_id = ? AND is_deleted = ?\", serverID, false).\n\t\tCount(&count).Error\n\treturn count, err\n}\n", - "internal/infrastructure/rms/client.go": "package rms\n\nimport (\n\t\"bytes\"\n\t\"crypto/sha1\"\n\t\"encoding/json\"\n\t\"encoding/xml\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/shopspring/decimal\"\n\t\"go.uber.org/zap\"\n\n\t\"rmser/internal/domain/catalog\"\n\t\"rmser/internal/domain/invoices\"\n\t\"rmser/internal/domain/recipes\"\n\t\"rmser/internal/domain/suppliers\"\n\t\"rmser/pkg/logger\"\n)\n\nconst (\n\ttokenTTL = 45 * time.Minute // Время жизни токена до принудительного обновления\n)\n\n// ClientI интерфейс\ntype ClientI interface {\n\tAuth() error\n\tLogout() error\n\tFetchCatalog() ([]catalog.Product, error)\n\tFetchStores() ([]catalog.Store, error)\n\tFetchSuppliers() ([]suppliers.Supplier, error)\n\tFetchMeasureUnits() ([]catalog.MeasureUnit, error)\n\tFetchRecipes(dateFrom, dateTo time.Time) ([]recipes.Recipe, error)\n\tFetchInvoices(from, to time.Time) ([]invoices.Invoice, error)\n\tFetchStoreOperations(presetID string, from, to time.Time) ([]StoreReportItemXML, error)\n\tCreateIncomingInvoice(inv invoices.Invoice) (string, error)\n\tGetProductByID(id uuid.UUID) (*ProductFullDTO, error)\n\tUpdateProduct(product ProductFullDTO) (*ProductFullDTO, error)\n}\n\ntype Client struct {\n\tbaseURL string\n\tlogin string\n\tpasswordHash string\n\thttpClient *http.Client\n\n\t// Защита токена для конкурентного доступа\n\tmu sync.RWMutex\n\ttoken string\n\ttokenCreatedAt time.Time\n}\n\nfunc NewClient(baseURL, login, password string) *Client {\n\th := sha1.New()\n\th.Write([]byte(password))\n\tpassHash := fmt.Sprintf(\"%x\", h.Sum(nil))\n\n\treturn &Client{\n\t\tbaseURL: baseURL,\n\t\tlogin: login,\n\t\tpasswordHash: passHash,\n\t\thttpClient: &http.Client{Timeout: 60 * time.Second},\n\t}\n}\n\n// Auth выполняет вход\nfunc (c *Client) Auth() error {\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\n\treturn c.authUnsafe()\n}\n\n// authUnsafe - внутренняя логика авторизации без блокировок (вызывается внутри Lock)\nfunc (c *Client) authUnsafe() error {\n\tendpoint := c.baseURL + \"/resto/api/auth\"\n\n\tdata := url.Values{}\n\tdata.Set(\"login\", c.login)\n\tdata.Set(\"pass\", c.passwordHash)\n\n\treq, err := http.NewRequest(\"POST\", endpoint, strings.NewReader(data.Encode()))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"ошибка создания запроса auth: %w\", err)\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/x-www-form-urlencoded\")\n\n\tresp, err := c.httpClient.Do(req)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"ошибка сети auth: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn fmt.Errorf(\"ошибка авторизации (code %d): %s\", resp.StatusCode, string(body))\n\t}\n\n\tc.token = string(body)\n\tc.tokenCreatedAt = time.Now() // Запоминаем время получения\n\tlogger.Log.Info(\"RMS: Успешная авторизация\", zap.String(\"token_preview\", c.token[:5]+\"...\"))\n\treturn nil\n}\n\n// Logout освобождает лицензию\nfunc (c *Client) Logout() error {\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\n\treturn c.logoutUnsafe()\n}\n\n// logoutUnsafe - внутренняя логика логаута\nfunc (c *Client) logoutUnsafe() error {\n\tif c.token == \"\" {\n\t\treturn nil\n\t}\n\n\tendpoint := c.baseURL + \"/resto/api/logout\"\n\tdata := url.Values{}\n\tdata.Set(\"key\", c.token)\n\n\treq, err := http.NewRequest(\"POST\", endpoint, strings.NewReader(data.Encode()))\n\tif err == nil {\n\t\treq.Header.Set(\"Content-Type\", \"application/x-www-form-urlencoded\")\n\t\tresp, err := c.httpClient.Do(req)\n\t\tif err == nil {\n\t\t\tdefer resp.Body.Close()\n\t\t\tif resp.StatusCode == http.StatusOK {\n\t\t\t\tlogger.Log.Info(\"RMS: Токен освобожден\")\n\t\t\t} else {\n\t\t\t\tlogger.Log.Warn(\"RMS: Ошибка освобождения токена\", zap.Int(\"code\", resp.StatusCode))\n\t\t\t}\n\t\t}\n\t}\n\n\t// Сбрасываем токен в любом случае, даже если запрос не прошел (он все равно протухнет)\n\tc.token = \"\"\n\tc.tokenCreatedAt = time.Time{}\n\treturn nil\n}\n\n// ensureToken проверяет срок жизни токена и обновляет его при необходимости\nfunc (c *Client) ensureToken() error {\n\tc.mu.RLock()\n\ttoken := c.token\n\tcreatedAt := c.tokenCreatedAt\n\tc.mu.RUnlock()\n\n\t// Если токена нет или он протух\n\tif token == \"\" || time.Since(createdAt) > tokenTTL {\n\t\tc.mu.Lock()\n\t\tdefer c.mu.Unlock()\n\n\t\t// Double check locking (вдруг другая горутина уже обновила)\n\t\tif c.token != \"\" && time.Since(c.tokenCreatedAt) <= tokenTTL {\n\t\t\treturn nil\n\t\t}\n\n\t\tif c.token != \"\" {\n\t\t\tlogger.Log.Info(\"RMS: Время жизни токена истекло (>45 мин), пересоздание...\")\n\t\t\t_ = c.logoutUnsafe() // Пытаемся освободить старый\n\t\t}\n\n\t\treturn c.authUnsafe()\n\t}\n\treturn nil\n}\n\n// doRequest выполняет запрос с автоматическим управлением токеном\nfunc (c *Client) doRequest(method, path string, queryParams map[string]string) (*http.Response, error) {\n\t// 1. Проверка времени жизни (45 минут)\n\tif err := c.ensureToken(); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Читаем токен под RLock\n\tc.mu.RLock()\n\tcurrentToken := c.token\n\tc.mu.RUnlock()\n\n\tbuildURL := func() string {\n\t\tu, _ := url.Parse(c.baseURL + path)\n\t\tq := u.Query()\n\t\tq.Set(\"key\", currentToken)\n\t\tfor k, v := range queryParams {\n\t\t\tq.Set(k, v)\n\t\t}\n\t\tu.RawQuery = q.Encode()\n\t\treturn u.String()\n\t}\n\n\treq, _ := http.NewRequest(method, buildURL(), nil)\n\tresp, err := c.httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// 2. Реактивная обработка 401 (если сервер перезагрузился или убил сессию раньше времени)\n\tif resp.StatusCode == http.StatusUnauthorized {\n\t\tresp.Body.Close()\n\t\tlogger.Log.Warn(\"RMS: Получен 401 Unauthorized, принудительная ре-авторизация...\")\n\n\t\tc.mu.Lock()\n\t\t// Сбрасываем токен и логинимся заново\n\t\tc.token = \"\"\n\t\tauthErr := c.authUnsafe()\n\t\tc.mu.Unlock()\n\n\t\tif authErr != nil {\n\t\t\treturn nil, authErr\n\t\t}\n\n\t\t// Повторяем запрос с новым токеном\n\t\tc.mu.RLock()\n\t\tcurrentToken = c.token\n\t\tc.mu.RUnlock()\n\n\t\treq, _ = http.NewRequest(method, buildURL(), nil)\n\t\treturn c.httpClient.Do(req)\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tdefer resp.Body.Close()\n\t\tbody, _ := io.ReadAll(resp.Body)\n\t\treturn nil, fmt.Errorf(\"api error: code=%d, body=%s\", resp.StatusCode, string(body))\n\t}\n\n\treturn resp, nil\n}\n\n// --- Методы получения данных (без изменений логики парсинга) ---\n\nfunc (c *Client) FetchCatalog() ([]catalog.Product, error) {\n\tvar products []catalog.Product\n\n\t// Группы\n\trespGroups, err := c.doRequest(\"GET\", \"/resto/api/v2/entities/products/group/list\", map[string]string{\n\t\t\"includeDeleted\": \"true\",\n\t})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"get groups error: %w\", err)\n\t}\n\tdefer respGroups.Body.Close()\n\n\tvar groupDTOs []GroupDTO\n\tif err := json.NewDecoder(respGroups.Body).Decode(&groupDTOs); err != nil {\n\t\treturn nil, fmt.Errorf(\"json decode groups error: %w\", err)\n\t}\n\n\t// Товары\n\trespProds, err := c.doRequest(\"GET\", \"/resto/api/v2/entities/products/list\", map[string]string{\n\t\t\"includeDeleted\": \"true\",\n\t})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"get products error: %w\", err)\n\t}\n\tdefer respProds.Body.Close()\n\n\tvar prodDTOs []ProductDTO\n\tif err := json.NewDecoder(respProds.Body).Decode(&prodDTOs); err != nil {\n\t\treturn nil, fmt.Errorf(\"json decode products error: %w\", err)\n\t}\n\n\t// Маппинг групп\n\tfor _, g := range groupDTOs {\n\t\tid, _ := uuid.Parse(g.ID)\n\t\tvar parentID *uuid.UUID\n\t\tif g.ParentID != nil {\n\t\t\tif pid, err := uuid.Parse(*g.ParentID); err == nil {\n\t\t\t\tparentID = &pid\n\t\t\t}\n\t\t}\n\t\tproducts = append(products, catalog.Product{\n\t\t\tID: id,\n\t\t\tParentID: parentID,\n\t\t\tName: g.Name,\n\t\t\tNum: g.Num,\n\t\t\tCode: g.Code,\n\t\t\tType: \"GROUP\",\n\t\t\tIsDeleted: g.Deleted,\n\t\t})\n\t}\n\n\t// Маппинг товаров\n\tfor _, p := range prodDTOs {\n\t\tid, _ := uuid.Parse(p.ID)\n\t\tvar parentID *uuid.UUID\n\t\tif p.ParentID != nil {\n\t\t\tif pid, err := uuid.Parse(*p.ParentID); err == nil {\n\t\t\t\tparentID = &pid\n\t\t\t}\n\t\t}\n\n\t\t// Обработка MainUnit\n\t\tvar mainUnitID *uuid.UUID\n\t\tif p.MainUnit != nil {\n\t\t\tif uid, err := uuid.Parse(*p.MainUnit); err == nil {\n\t\t\t\tmainUnitID = &uid\n\t\t\t}\n\t\t}\n\n\t\t// Маппинг фасовок\n\t\tvar containers []catalog.ProductContainer\n\t\tfor _, contDto := range p.Containers {\n\t\t\tcID, err := uuid.Parse(contDto.ID)\n\t\t\tif err == nil {\n\t\t\t\tcontainers = append(containers, catalog.ProductContainer{\n\t\t\t\t\tID: cID,\n\t\t\t\t\tProductID: id,\n\t\t\t\t\tName: contDto.Name,\n\t\t\t\t\tCount: decimal.NewFromFloat(contDto.Count),\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\n\t\tproducts = append(products, catalog.Product{\n\t\t\tID: id,\n\t\t\tParentID: parentID,\n\t\t\tName: p.Name,\n\t\t\tNum: p.Num,\n\t\t\tCode: p.Code,\n\t\t\tType: p.Type,\n\t\t\tUnitWeight: decimal.NewFromFloat(p.UnitWeight),\n\t\t\tUnitCapacity: decimal.NewFromFloat(p.UnitCapacity),\n\t\t\tMainUnitID: mainUnitID,\n\t\t\tContainers: containers,\n\t\t\tIsDeleted: p.Deleted,\n\t\t})\n\t}\n\n\treturn products, nil\n}\n\n// FetchStores загружает список складов (Account -> INVENTORY_ASSETS)\nfunc (c *Client) FetchStores() ([]catalog.Store, error) {\n\tresp, err := c.doRequest(\"GET\", \"/resto/api/v2/entities/list\", map[string]string{\n\t\t\"rootType\": \"Account\",\n\t\t\"includeDeleted\": \"false\",\n\t})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"get stores error: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tvar dtos []AccountDTO\n\tif err := json.NewDecoder(resp.Body).Decode(&dtos); err != nil {\n\t\treturn nil, fmt.Errorf(\"json decode stores error: %w\", err)\n\t}\n\n\tvar stores []catalog.Store\n\tfor _, d := range dtos {\n\t\t// Фильтруем только склады\n\t\tif d.Type != \"INVENTORY_ASSETS\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tid, err := uuid.Parse(d.ID)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar parentCorpID uuid.UUID\n\t\tif d.ParentCorporateID != nil {\n\t\t\tif parsed, err := uuid.Parse(*d.ParentCorporateID); err == nil {\n\t\t\t\tparentCorpID = parsed\n\t\t\t}\n\t\t}\n\n\t\tstores = append(stores, catalog.Store{\n\t\t\tID: id,\n\t\t\tName: d.Name,\n\t\t\tParentCorporateID: parentCorpID,\n\t\t\tIsDeleted: d.Deleted,\n\t\t})\n\t}\n\n\treturn stores, nil\n}\n\n// FetchMeasureUnits загружает справочник единиц измерения\nfunc (c *Client) FetchMeasureUnits() ([]catalog.MeasureUnit, error) {\n\t// rootType=MeasureUnit согласно документации iiko\n\tresp, err := c.doRequest(\"GET\", \"/resto/api/v2/entities/list\", map[string]string{\n\t\t\"rootType\": \"MeasureUnit\",\n\t\t\"includeDeleted\": \"false\",\n\t})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"get measure units error: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tvar dtos []GenericEntityDTO\n\tif err := json.NewDecoder(resp.Body).Decode(&dtos); err != nil {\n\t\treturn nil, fmt.Errorf(\"json decode error: %w\", err)\n\t}\n\n\tvar result []catalog.MeasureUnit\n\tfor _, d := range dtos {\n\t\tid, err := uuid.Parse(d.ID)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tresult = append(result, catalog.MeasureUnit{\n\t\t\tID: id,\n\t\t\tName: d.Name,\n\t\t\tCode: d.Code,\n\t\t})\n\t}\n\treturn result, nil\n}\n\nfunc (c *Client) FetchRecipes(dateFrom, dateTo time.Time) ([]recipes.Recipe, error) {\n\tparams := map[string]string{\n\t\t\"dateFrom\": dateFrom.Format(\"2006-01-02\"),\n\t}\n\tif !dateTo.IsZero() {\n\t\tparams[\"dateTo\"] = dateTo.Format(\"2006-01-02\")\n\t}\n\n\tresp, err := c.doRequest(\"GET\", \"/resto/api/v2/assemblyCharts/getAll\", params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"get recipes error: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tvar apiResp AssemblyChartsResponse\n\tif err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"json decode recipes error: %w\", err)\n\t}\n\n\tvar allrecipes []recipes.Recipe\n\tfor _, chart := range apiResp.AssemblyCharts {\n\t\trID, _ := uuid.Parse(chart.ID)\n\t\tpID, _ := uuid.Parse(chart.AssembledProductID)\n\n\t\tdf, _ := time.Parse(\"2006-01-02\", chart.DateFrom)\n\t\tvar dt *time.Time\n\t\tif chart.DateTo != nil {\n\t\t\tif t, err := time.Parse(\"2006-01-02\", *chart.DateTo); err == nil {\n\t\t\t\tdt = &t\n\t\t\t}\n\t\t}\n\n\t\tvar items []recipes.RecipeItem\n\t\tfor _, item := range chart.Items {\n\t\t\tiPID, _ := uuid.Parse(item.ProductID)\n\n\t\t\t// FIX: Генерируем уникальный ID для каждой строки в нашей БД,\n\t\t\t// чтобы избежать конфликтов PK при переиспользовании строк в iiko.\n\t\t\titems = append(items, recipes.RecipeItem{\n\t\t\t\tID: uuid.New(),\n\t\t\t\tRecipeID: rID,\n\t\t\t\tProductID: iPID,\n\t\t\t\tAmountIn: decimal.NewFromFloat(item.AmountIn),\n\t\t\t\tAmountOut: decimal.NewFromFloat(item.AmountOut),\n\t\t\t})\n\t\t}\n\n\t\tallrecipes = append(allrecipes, recipes.Recipe{\n\t\t\tID: rID,\n\t\t\tProductID: pID,\n\t\t\tDateFrom: df,\n\t\t\tDateTo: dt,\n\t\t\tItems: items,\n\t\t})\n\t}\n\n\treturn allrecipes, nil\n}\n\nfunc (c *Client) FetchInvoices(from, to time.Time) ([]invoices.Invoice, error) {\n\tparams := map[string]string{\n\t\t\"from\": from.Format(\"2006-01-02\"),\n\t\t\"to\": to.Format(\"2006-01-02\"),\n\t\t\"currentYear\": \"false\",\n\t}\n\n\tresp, err := c.doRequest(\"GET\", \"/resto/api/documents/export/incomingInvoice\", params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"get invoices error: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tvar xmlData IncomingInvoiceListXML\n\tif err := xml.NewDecoder(resp.Body).Decode(&xmlData); err != nil {\n\t\treturn nil, fmt.Errorf(\"xml decode invoices error: %w\", err)\n\t}\n\n\tvar allinvoices []invoices.Invoice\n\tfor _, doc := range xmlData.Documents {\n\t\tdocID, _ := uuid.Parse(doc.ID)\n\t\tsupID, _ := uuid.Parse(doc.Supplier)\n\t\tstoreID, _ := uuid.Parse(doc.DefaultStore)\n\t\tdateInc, _ := time.Parse(\"2006-01-02T15:04:05\", doc.DateIncoming)\n\n\t\tvar items []invoices.InvoiceItem\n\t\tfor _, it := range doc.Items {\n\t\t\tpID, _ := uuid.Parse(it.Product)\n\t\t\titems = append(items, invoices.InvoiceItem{\n\t\t\t\tInvoiceID: docID,\n\t\t\t\tProductID: pID,\n\t\t\t\tAmount: decimal.NewFromFloat(it.Amount),\n\t\t\t\tPrice: decimal.NewFromFloat(it.Price),\n\t\t\t\tSum: decimal.NewFromFloat(it.Sum),\n\t\t\t\tVatSum: decimal.NewFromFloat(it.VatSum),\n\t\t\t})\n\t\t}\n\n\t\tallinvoices = append(allinvoices, invoices.Invoice{\n\t\t\tID: docID,\n\t\t\tDocumentNumber: doc.DocumentNumber,\n\t\t\tIncomingDocumentNumber: doc.IncomingDocumentNumber,\n\t\t\tDateIncoming: dateInc,\n\t\t\tSupplierID: supID,\n\t\t\tDefaultStoreID: storeID,\n\t\t\tStatus: doc.Status,\n\t\t\tItems: items,\n\t\t})\n\t}\n\n\treturn allinvoices, nil\n}\n\n// FetchStoreOperations загружает складской отчет по ID пресета\nfunc (c *Client) FetchStoreOperations(presetID string, from, to time.Time) ([]StoreReportItemXML, error) {\n\tparams := map[string]string{\n\t\t\"presetId\": presetID,\n\t\t\"dateFrom\": from.Format(\"02.01.2006\"), // В документации формат DD.MM.YYYY\n\t\t\"dateTo\": to.Format(\"02.01.2006\"),\n\t}\n\n\tresp, err := c.doRequest(\"GET\", \"/resto/api/reports/storeOperations\", params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"fetch store operations error: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tvar report StoreReportResponse\n\tif err := xml.NewDecoder(resp.Body).Decode(&report); err != nil {\n\t\t// Иногда RMS возвращает пустой ответ или ошибку текстом при отсутствии данных\n\t\treturn nil, fmt.Errorf(\"xml decode store operations error: %w\", err)\n\t}\n\n\treturn report.Items, nil\n}\n\n// CreateIncomingInvoice отправляет накладную в iiko\nfunc (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) {\n\t// 1. Маппинг Domain -> XML DTO\n\n\t// Статус по умолчанию NEW, если не передан\n\tstatus := inv.Status\n\tif status == \"\" {\n\t\tstatus = \"NEW\"\n\t}\n\n\t// Комментарий по умолчанию, если пустой\n\tcomment := inv.Comment\n\tif comment == \"\" {\n\t\tcomment = \"Loaded via RMSER OCR\"\n\t}\n\n\treqDTO := IncomingInvoiceImportXML{\n\t\tDocumentNumber: inv.DocumentNumber,\n\t\tIncomingDocumentNumber: inv.IncomingDocumentNumber, // Присваиваем входящий номер документа из домена\n\t\tDateIncoming: inv.DateIncoming.Format(\"02.01.2006\"),\n\t\tDefaultStore: inv.DefaultStoreID.String(),\n\t\tSupplier: inv.SupplierID.String(),\n\t\tStatus: status,\n\t\tComment: comment,\n\t}\n\n\tif inv.ID != uuid.Nil {\n\t\treqDTO.ID = inv.ID.String()\n\t}\n\n\tfor i, item := range inv.Items {\n\t\tamount, _ := item.Amount.Float64()\n\t\tprice, _ := item.Price.Float64()\n\t\tsum, _ := item.Sum.Float64()\n\n\t\txmlItem := IncomingInvoiceImportItemXML{\n\t\t\tProductID: item.ProductID.String(),\n\t\t\tAmount: amount,\n\t\t\tPrice: price,\n\t\t\tSum: sum,\n\t\t\tNum: i + 1,\n\t\t\tStore: inv.DefaultStoreID.String(),\n\t\t}\n\n\t\tif item.ContainerID != nil && *item.ContainerID != uuid.Nil {\n\t\t\txmlItem.ContainerId = item.ContainerID.String()\n\t\t}\n\n\t\treqDTO.ItemsWrapper.Items = append(reqDTO.ItemsWrapper.Items, xmlItem)\n\t}\n\n\t// 2. Маршалинг в XML\n\txmlBytes, err := xml.Marshal(reqDTO)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"xml marshal error: %w\", err)\n\t}\n\t// Добавляем XML header вручную\n\txmlPayload := []byte(xml.Header + string(xmlBytes))\n\n\t// 3. Получение токена\n\tif err := c.ensureToken(); err != nil {\n\t\treturn \"\", err\n\t}\n\n\tc.mu.RLock()\n\ttoken := c.token\n\tc.mu.RUnlock()\n\n\t// 4. Формирование URL\n\tendpoint, _ := url.Parse(c.baseURL + \"/resto/api/documents/import/incomingInvoice\")\n\tq := endpoint.Query()\n\tq.Set(\"key\", token)\n\tendpoint.RawQuery = q.Encode()\n\n\tfullURL := endpoint.String()\n\n\t// --- ЛОГИРОВАНИЕ ЗАПРОСА (URL + BODY) ---\n\t// Логируем как Info, чтобы точно увидеть в консоли при отладке\n\tlogger.Log.Info(\"RMS POST Request Debug\",\n\t\tzap.String(\"method\", \"POST\"),\n\t\tzap.String(\"url\", fullURL),\n\t\tzap.String(\"body_payload\", string(xmlPayload)),\n\t)\n\n\t// 5. Отправка\n\treq, err := http.NewRequest(\"POST\", fullURL, bytes.NewReader(xmlPayload))\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/xml\")\n\n\tresp, err := c.httpClient.Do(req)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"network error: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\t// Читаем ответ\n\trespBody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// Логируем ответ для симметрии\n\tlogger.Log.Info(\"RMS POST Response Debug\",\n\t\tzap.Int(\"status_code\", resp.StatusCode),\n\t\tzap.String(\"response_body\", string(respBody)),\n\t)\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn \"\", fmt.Errorf(\"http error %d: %s\", resp.StatusCode, string(respBody))\n\t}\n\n\tvar result DocumentValidationResult\n\tif err := xml.Unmarshal(respBody, &result); err != nil {\n\t\treturn \"\", fmt.Errorf(\"xml response unmarshal error: %w\", err)\n\t}\n\n\tif !result.Valid {\n\t\tlogger.Log.Warn(\"RMS Invoice Import Failed\",\n\t\t\tzap.String(\"error\", result.ErrorMessage),\n\t\t\tzap.String(\"additional\", result.AdditionalInfo),\n\t\t)\n\t\treturn \"\", fmt.Errorf(\"iiko validation failed: %s (info: %s)\", result.ErrorMessage, result.AdditionalInfo)\n\t}\n\n\treturn result.DocumentNumber, nil\n}\n\n// GetProductByID получает полную структуру товара по ID (через /list?ids=...)\nfunc (c *Client) GetProductByID(id uuid.UUID) (*ProductFullDTO, error) {\n\t// Параметр ids должен быть списком. iiko ожидает ids=UUID\n\tparams := map[string]string{\n\t\t\"ids\": id.String(),\n\t\t\"includeDeleted\": \"false\",\n\t}\n\n\tresp, err := c.doRequest(\"GET\", \"/resto/api/v2/entities/products/list\", params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"request error: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"rms error code %d\", resp.StatusCode)\n\t}\n\n\t// Ответ - это массив товаров\n\tvar products []ProductFullDTO\n\tif err := json.NewDecoder(resp.Body).Decode(&products); err != nil {\n\t\treturn nil, fmt.Errorf(\"json decode error: %w\", err)\n\t}\n\n\tif len(products) == 0 {\n\t\treturn nil, fmt.Errorf(\"product not found in rms\")\n\t}\n\n\treturn &products[0], nil\n}\n\n// UpdateProduct отправляет полную структуру товара на обновление (/update)\nfunc (c *Client) UpdateProduct(product ProductFullDTO) (*ProductFullDTO, error) {\n\t// Маршалим тело\n\tbodyBytes, err := json.Marshal(product)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"json marshal error: %w\", err)\n\t}\n\n\t// Используем doRequestPost (надо реализовать или вручную, т.к. doRequest у нас GET-ориентирован в текущем коде был прост)\n\t// Расширим логику doRequest или напишем тут, т.к. это POST с JSON body\n\tif err := c.ensureToken(); err != nil {\n\t\treturn nil, err\n\t}\n\tc.mu.RLock()\n\ttoken := c.token\n\tc.mu.RUnlock()\n\n\tendpoint := c.baseURL + \"/resto/api/v2/entities/products/update?key=\" + token\n\n\treq, err := http.NewRequest(\"POST\", endpoint, bytes.NewReader(bodyBytes))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\tresp, err := c.httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\trespBody, _ := io.ReadAll(resp.Body)\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"update failed (code %d): %s\", resp.StatusCode, string(respBody))\n\t}\n\n\tvar result UpdateEntityResponse\n\tif err := json.Unmarshal(respBody, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"response unmarshal error: %w\", err)\n\t}\n\n\tif result.Result != \"SUCCESS\" {\n\t\t// Собираем ошибки\n\t\terrMsg := \"rms update error: \"\n\t\tfor _, e := range result.Errors {\n\t\t\terrMsg += fmt.Sprintf(\"[%s] %s; \", e.Code, e.Value)\n\t\t}\n\t\treturn nil, fmt.Errorf(errMsg)\n\t}\n\n\tif result.Response == nil {\n\t\treturn nil, fmt.Errorf(\"empty response from rms after update\")\n\t}\n\n\treturn result.Response, nil\n}\n\n// GetServerInfo пытается получить информацию о сервере (имя, версия) без авторизации.\n// Использует endpoint /resto/getServerMonitoringInfo.jsp\nfunc GetServerInfo(baseURL string) (*ServerMonitoringInfoDTO, error) {\n\t// Формируем URL. Убираем слэш в конце, если есть.\n\turl := strings.TrimRight(baseURL, \"/\") + \"/resto/getServerMonitoringInfo.jsp\"\n\n\tlogger.Log.Info(\"RMS: Requesting server info\", zap.String(\"url\", url))\n\n\tclient := &http.Client{Timeout: 5 * time.Second}\n\tresp, err := client.Get(url)\n\tif err != nil {\n\t\tlogger.Log.Error(\"RMS: Monitoring connection failed\", zap.Error(err))\n\t\treturn nil, fmt.Errorf(\"connection error: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbodyBytes, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"read body error: %w\", err)\n\t}\n\n\tlogger.Log.Info(\"RMS: Monitoring Response\",\n\t\tzap.Int(\"status\", resp.StatusCode),\n\t\tzap.String(\"body\", string(bodyBytes)))\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"status code %d\", resp.StatusCode)\n\t}\n\n\tvar info ServerMonitoringInfoDTO\n\n\t// Пробуем JSON (так как в логе пришел JSON)\n\tif err := json.Unmarshal(bodyBytes, &info); err != nil {\n\t\t// Если вдруг JSON не прошел, можно попробовать XML как фоллбек (для старых версий)\n\t\tlogger.Log.Warn(\"RMS: JSON decode failed, trying XML...\", zap.Error(err))\n\t\tif xmlErr := xml.Unmarshal(bodyBytes, &info); xmlErr != nil {\n\t\t\treturn nil, fmt.Errorf(\"decode error (json & xml failed): %w\", err)\n\t\t}\n\t}\n\n\treturn &info, nil\n}\n\n// FetchSuppliers загружает список поставщиков через XML API\nfunc (c *Client) FetchSuppliers() ([]suppliers.Supplier, error) {\n\t// Endpoint /resto/api/suppliers\n\tresp, err := c.doRequest(\"GET\", \"/resto/api/suppliers\", nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"get suppliers error: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tvar xmlData SuppliersListXML\n\tif err := xml.NewDecoder(resp.Body).Decode(&xmlData); err != nil {\n\t\treturn nil, fmt.Errorf(\"xml decode suppliers error: %w\", err)\n\t}\n\n\tvar result []suppliers.Supplier\n\tfor _, emp := range xmlData.Employees {\n\t\tid, err := uuid.Parse(emp.ID)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tisDeleted := emp.Deleted == \"true\"\n\n\t\tresult = append(result, suppliers.Supplier{\n\t\t\tID: id,\n\t\t\tName: emp.Name,\n\t\t\tCode: emp.Code,\n\t\t\tINN: emp.TaxpayerIdNumber,\n\t\t\tIsDeleted: isDeleted,\n\t\t\t// RMSServerID проставляется в сервисе перед сохранением\n\t\t})\n\t}\n\n\treturn result, nil\n}\n", - "internal/infrastructure/rms/dto.go": "package rms\n\nimport (\n\t\"encoding/xml\"\n)\n\n// --- JSON DTOs (V2 API) ---\n\ntype ProductDTO struct {\n\tID string `json:\"id\"`\n\tParentID *string `json:\"parent\"` // Может быть null\n\tName string `json:\"name\"`\n\tNum string `json:\"num\"` // Артикул\n\tCode string `json:\"code\"` // Код быстрого набора\n\tType string `json:\"type\"` // GOODS, DISH, PREPARED, etc.\n\tUnitWeight float64 `json:\"unitWeight\"`\n\tUnitCapacity float64 `json:\"unitCapacity\"`\n\tMainUnit *string `json:\"mainUnit\"`\n\tContainers []ContainerDTO `json:\"containers\"`\n\tDeleted bool `json:\"deleted\"`\n}\n\n// GenericEntityDTO используется для простых справочников (MeasureUnit и др.)\ntype GenericEntityDTO struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tCode string `json:\"code\"`\n\tDeleted bool `json:\"deleted\"`\n}\n\n// AccountDTO используется для парсинга складов (INVENTORY_ASSETS)\ntype AccountDTO struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tType string `json:\"type\"` // Нас интересует \"INVENTORY_ASSETS\"\n\tParentCorporateID *string `json:\"parentCorporateId\"`\n\tDeleted bool `json:\"deleted\"`\n}\n\n// ContainerDTO - фасовка из iiko\ntype ContainerDTO struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"` // Название фасовки (напр. \"Коробка\")\n\tCount float64 `json:\"count\"` // Сколько базовых единиц в фасовке\n}\n\ntype GroupDTO struct {\n\tID string `json:\"id\"`\n\tParentID *string `json:\"parent\"`\n\tName string `json:\"name\"`\n\tNum string `json:\"num\"`\n\tCode string `json:\"code\"`\n\tDescription string `json:\"description\"`\n\tDeleted bool `json:\"deleted\"`\n}\n\ntype AssemblyChartsResponse struct {\n\tAssemblyCharts []AssemblyChartDTO `json:\"assemblyCharts\"`\n\t// preparedCharts и другие поля пока опускаем, если не нужны для базового импорта\n}\n\ntype AssemblyChartDTO struct {\n\tID string `json:\"id\"`\n\tAssembledProductID string `json:\"assembledProductId\"`\n\tDateFrom string `json:\"dateFrom\"` // Format: \"2018-01-29\" (yyyy-MM-dd)\n\tDateTo *string `json:\"dateTo\"` // Nullable\n\tItems []AssemblyItemDTO `json:\"items\"`\n}\n\ntype AssemblyItemDTO struct {\n\tID string `json:\"id\"` // Добавили поле ID строки техкарты\n\tProductID string `json:\"productId\"`\n\tAmountIn float64 `json:\"amountIn\"`\n\tAmountOut float64 `json:\"amountOut\"`\n}\n\n// ProductFullDTO используется для получения (list?ids=...) и обновления (update) товара целиком.\ntype ProductFullDTO struct {\n\tID string `json:\"id\"`\n\tDeleted bool `json:\"deleted\"`\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tNum string `json:\"num\"`\n\tCode string `json:\"code\"`\n\tParent *string `json:\"parent\"` // null или UUID\n\tModifiers []interface{} `json:\"modifiers\"` // Оставляем interface{}, чтобы не мапить сложную структуру, если не меняем её\n\tTaxCategory *string `json:\"taxCategory\"`\n\tCategory *string `json:\"category\"`\n\tAccountingCategory *string `json:\"accountingCategory\"`\n\tColor map[string]int `json:\"color\"`\n\tFontColor map[string]int `json:\"fontColor\"`\n\tFrontImageID *string `json:\"frontImageId\"`\n\tPosition *int `json:\"position\"`\n\tModifierSchemaID *string `json:\"modifierSchemaId\"`\n\tMainUnit string `json:\"mainUnit\"` // Обязательное поле\n\tExcludedSections []string `json:\"excludedSections\"` // Set\n\tDefaultSalePrice float64 `json:\"defaultSalePrice\"`\n\tPlaceType *string `json:\"placeType\"`\n\tDefaultIncInMenu bool `json:\"defaultIncludedInMenu\"`\n\tType string `json:\"type\"` // GOODS, DISH...\n\tUnitWeight float64 `json:\"unitWeight\"`\n\tUnitCapacity float64 `json:\"unitCapacity\"`\n\tStoreBalanceLevels []StoreBalanceLevel `json:\"storeBalanceLevels\"`\n\tUseBalanceForSell bool `json:\"useBalanceForSell\"`\n\tContainers []ContainerFullDTO `json:\"containers\"`\n\tProductScaleID *string `json:\"productScaleId\"`\n\tBarcodes []interface{} `json:\"barcodes\"`\n\tColdLossPercent float64 `json:\"coldLossPercent\"`\n\tHotLossPercent float64 `json:\"hotLossPercent\"`\n\tOuterCode *string `json:\"outerEconomicActivityNomenclatureCode\"`\n\tAllergenGroups []interface{} `json:\"allergenGroups\"`\n\tEstPurchasePrice float64 `json:\"estimatedPurchasePrice\"`\n\tCanSetOpenPrice bool `json:\"canSetOpenPrice\"`\n\tNotInStoreMovement bool `json:\"notInStoreMovement\"`\n}\n\ntype StoreBalanceLevel struct {\n\tStoreID string `json:\"storeId\"`\n\tMinBalanceLevel *float64 `json:\"minBalanceLevel\"`\n\tMaxBalanceLevel *float64 `json:\"maxBalanceLevel\"`\n}\n\ntype ContainerFullDTO struct {\n\tID *string `json:\"id,omitempty\"` // При создании новой фасовки ID пустой/null\n\tNum string `json:\"num\"` // Порядковый номер? Обычно строка.\n\tName string `json:\"name\"`\n\tCount float64 `json:\"count\"`\n\tMinContainerWeight float64 `json:\"minContainerWeight\"`\n\tMaxContainerWeight float64 `json:\"maxContainerWeight\"`\n\tContainerWeight float64 `json:\"containerWeight\"`\n\tFullContainerWeight float64 `json:\"fullContainerWeight\"`\n\tBackwardRecalculation bool `json:\"backwardRecalculation\"`\n\tDeleted bool `json:\"deleted\"`\n\tUseInFront bool `json:\"useInFront\"`\n}\n\n// --- XML DTOs (Legacy API) ---\n\ntype IncomingInvoiceListXML struct {\n\tXMLName xml.Name `xml:\"incomingInvoiceDtoes\"`\n\tDocuments []IncomingInvoiceXML `xml:\"document\"`\n}\n\ntype IncomingInvoiceXML struct {\n\tID string `xml:\"id\"`\n\tDocumentNumber string `xml:\"documentNumber\"`\n\tIncomingDocumentNumber string `xml:\"incomingDocumentNumber\"`\n\tDateIncoming string `xml:\"dateIncoming\"` // Format: yyyy-MM-ddTHH:mm:ss\n\tStatus string `xml:\"status\"` // PROCESSED, NEW, DELETED\n\tSupplier string `xml:\"supplier\"` // GUID\n\tDefaultStore string `xml:\"defaultStore\"` // GUID\n\tItems []InvoiceItemXML `xml:\"items>item\"`\n}\n\ntype InvoiceItemXML struct {\n\tProduct string `xml:\"product\"` // GUID\n\tAmount float64 `xml:\"amount\"` // Количество в основных единицах\n\tPrice float64 `xml:\"price\"` // Цена за единицу\n\tSum float64 `xml:\"sum\"` // Сумма без скидки (обычно)\n\tVatSum float64 `xml:\"vatSum\"` // Сумма НДС\n}\n\n// --- XML DTOs (Store Reports) ---\n\ntype StoreReportResponse struct {\n\tXMLName xml.Name `xml:\"storeReportItemDtoes\"`\n\tItems []StoreReportItemXML `xml:\"storeReportItemDto\"`\n}\n\ntype StoreReportItemXML struct {\n\t// Основные идентификаторы\n\tProductID string `xml:\"product\"` // GUID товара\n\tProductGroup string `xml:\"productGroup\"` // GUID группы\n\tStore string `xml:\"primaryStore\"` // GUID склада\n\tDocumentID string `xml:\"documentId\"` // GUID документа\n\tDocumentNum string `xml:\"documentNum\"` // Номер документа (строка)\n\n\t// Типы (ENUMs)\n\tDocumentType string `xml:\"documentType\"` // Например: INCOMING_INVOICE\n\tTransactionType string `xml:\"type\"` // Например: INVOICE, WRITEOFF\n\n\t// Финансы и количество\n\tAmount float64 `xml:\"amount\"` // Количество\n\tSum float64 `xml:\"sum\"` // Сумма с НДС\n\tSumWithoutNds float64 `xml:\"sumWithoutNds\"` // Сумма без НДС\n\tCost float64 `xml:\"cost\"` // Себестоимость\n\n\t// Флаги и даты (используем строки для дат, так как парсинг делаем в сервисе)\n\tIncoming bool `xml:\"incoming\"`\n\tDate string `xml:\"date\"`\n\tOperationalDate string `xml:\"operationalDate\"`\n}\n\n// --- XML DTOs (Import API) ---\n\n// IncomingInvoiceImportXML описывает структуру для POST запроса импорта\ntype IncomingInvoiceImportXML struct {\n\tXMLName xml.Name `xml:\"document\"`\n\tID string `xml:\"id,omitempty\"` // GUID, если редактируем\n\tDocumentNumber string `xml:\"documentNumber,omitempty\"`\n\tIncomingDocumentNumber string `xml:\"incomingDocumentNumber,omitempty\"` // Входящий номер документа\n\tDateIncoming string `xml:\"dateIncoming,omitempty\"` // Format: dd.MM.yyyy\n\tInvoice string `xml:\"invoice,omitempty\"` // Номер счет-фактуры\n\tDefaultStore string `xml:\"defaultStore\"` // GUID склада (обязательно)\n\tSupplier string `xml:\"supplier\"` // GUID поставщика (обязательно)\n\tComment string `xml:\"comment,omitempty\"`\n\tStatus string `xml:\"status,omitempty\"` // NEW, PROCESSED\n\tItemsWrapper struct {\n\t\tItems []IncomingInvoiceImportItemXML `xml:\"item\"`\n\t} `xml:\"items\"`\n}\n\ntype IncomingInvoiceImportItemXML struct {\n\tProductID string `xml:\"product\"` // GUID товара\n\tAmount float64 `xml:\"amount\"` // Кол-во (в фасовках, если указан containerId)\n\tPrice float64 `xml:\"price\"` // Цена за единицу (за фасовку, если указан containerId)\n\tSum float64 `xml:\"sum,omitempty\"` // Сумма\n\tStore string `xml:\"store\"` // GUID склада\n\tContainerId string `xml:\"containerId,omitempty\"` // ID фасовки\n\tAmountUnit string `xml:\"amountUnit,omitempty\"` // GUID единицы измерения (можно опустить, если фасовка)\n\tNum int `xml:\"num,omitempty\"`\n}\n\n// DocumentValidationResult описывает ответ сервера при импорте\ntype DocumentValidationResult struct {\n\tXMLName xml.Name `xml:\"documentValidationResult\"`\n\tValid bool `xml:\"valid\"`\n\tWarning bool `xml:\"warning\"`\n\tDocumentNumber string `xml:\"documentNumber\"`\n\tOtherSuggestedNumber string `xml:\"otherSuggestedNumber\"`\n\tErrorMessage string `xml:\"errorMessage\"`\n\tAdditionalInfo string `xml:\"additionalInfo\"`\n}\n\n// --- Вспомогательные DTO для ответов (REST) ---\n\n// UpdateEntityResponse - ответ на /save или /update\ntype UpdateEntityResponse struct {\n\tResult string `json:\"result\"` // \"SUCCESS\" or \"ERROR\"\n\tResponse *ProductFullDTO `json:\"response\"`\n\tErrors []ErrorDTO `json:\"errors\"`\n}\n\ntype ErrorDTO struct {\n\tCode string `json:\"code\"`\n\tValue string `json:\"value\"`\n}\n\n// ServerMonitoringInfoDTO используется для парсинга ответа мониторинга\n// iiko может отдавать JSON, поэтому ставим json теги.\ntype ServerMonitoringInfoDTO struct {\n\tServerName string `json:\"serverName\" xml:\"serverName\"`\n\tVersion string `json:\"version\" xml:\"version\"`\n}\n\n// --- Suppliers XML (Legacy API /resto/api/suppliers) ---\n\ntype SuppliersListXML struct {\n\tXMLName xml.Name `xml:\"employees\"`\n\tEmployees []SupplierXML `xml:\"employee\"`\n}\n\ntype SupplierXML struct {\n\tID string `xml:\"id\"`\n\tName string `xml:\"name\"`\n\tCode string `xml:\"code\"`\n\tTaxpayerIdNumber string `xml:\"taxpayerIdNumber\"` // ИНН\n\tDeleted string `xml:\"deleted\"` // \"true\" / \"false\"\n}\n", - "internal/infrastructure/rms/factory.go": "package rms\n\nimport (\n\t\"fmt\"\n\t\"sync\"\n\n\t\"github.com/google/uuid\"\n\t\"go.uber.org/zap\"\n\n\t\"rmser/internal/domain/account\"\n\t\"rmser/pkg/crypto\"\n\t\"rmser/pkg/logger\"\n)\n\n// Factory управляет созданием и переиспользованием клиентов RMS\ntype Factory struct {\n\taccountRepo account.Repository\n\tcryptoManager *crypto.CryptoManager\n\n\t// Кэш активных клиентов: ServerID -> *Client\n\tmu sync.RWMutex\n\tclients map[uuid.UUID]*Client\n}\n\nfunc NewFactory(repo account.Repository, cm *crypto.CryptoManager) *Factory {\n\treturn &Factory{\n\t\taccountRepo: repo,\n\t\tcryptoManager: cm,\n\t\tclients: make(map[uuid.UUID]*Client),\n\t}\n}\n\n// GetClientForUser возвращает клиент для текущего активного сервера пользователя.\n// Использует личные или наследуемые (от Owner) учетные данные.\nfunc (f *Factory) GetClientForUser(userID uuid.UUID) (ClientI, error) {\n\t// 1. Пытаемся найти в кэше\n\tf.mu.RLock()\n\tclient, exists := f.clients[userID]\n\tf.mu.RUnlock()\n\n\tif exists {\n\t\treturn client, nil\n\t}\n\n\t// 2. Создаем новый клиент под блокировкой\n\tf.mu.Lock()\n\tdefer f.mu.Unlock()\n\n\t// Double check\n\tif client, exists := f.clients[userID]; exists {\n\t\treturn client, nil\n\t}\n\n\t// 3. Получаем креды из репозитория (учитывая фоллбэк на Owner'а)\n\tbaseURL, login, encryptedPass, err := f.accountRepo.GetActiveConnectionCredentials(userID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"ошибка получения настроек подключения: %w\", err)\n\t}\n\n\t// 4. Расшифровка пароля\n\tplainPass, err := f.cryptoManager.Decrypt(encryptedPass)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"ошибка расшифровки пароля RMS: %w\", err)\n\t}\n\n\t// 5. Создание клиента\n\tnewClient := NewClient(baseURL, login, plainPass)\n\tf.clients[userID] = newClient\n\n\tlogger.Log.Info(\"RMS Factory: Client created for user\",\n\t\tzap.String(\"user_id\", userID.String()),\n\t\tzap.String(\"login\", login),\n\t\tzap.String(\"url\", baseURL))\n\n\treturn newClient, nil\n}\n\n// CreateClientFromRawCredentials создает клиент без сохранения в кэш (для тестов подключения)\nfunc (f *Factory) CreateClientFromRawCredentials(url, login, password string) *Client {\n\treturn NewClient(url, login, password)\n}\n\n// ClearCacheForUser сбрасывает кэш пользователя (при смене сервера или выходе)\nfunc (f *Factory) ClearCacheForUser(userID uuid.UUID) {\n\tf.mu.Lock()\n\tdefer f.mu.Unlock()\n\tdelete(f.clients, userID)\n}\n\n// ClearCacheForServer сбрасывает кэш для ВСЕХ пользователей сервера (например, при смене пароля владельцем)\n// Это дорогая операция, но необходимая при изменении общих кредов.\nfunc (f *Factory) ClearCacheForServer(serverID uuid.UUID) {\n\t// Пока не реализовано эффективно (нужен обратный индекс).\n\t// Для MVP можно просто очистить весь кэш или оставить как есть,\n\t// так как токены iiko все равно протухнут.\n}\n", - "internal/infrastructure/yookassa/client.go": "package yookassa\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/shopspring/decimal\"\n)\n\ntype Client struct {\n\tshopID string\n\tsecretKey string\n\tbaseURL string\n\thttpClient *http.Client\n}\n\nfunc NewClient(shopID, secretKey string) *Client {\n\treturn &Client{\n\t\tshopID: shopID,\n\t\tsecretKey: secretKey,\n\t\tbaseURL: \"https://api.yookassa.ru/v3\",\n\t\thttpClient: &http.Client{Timeout: 15 * time.Second},\n\t}\n}\n\nfunc (c *Client) CreatePayment(ctx context.Context, amount decimal.Decimal, description string, orderID uuid.UUID, returnURL string) (*PaymentResponse, error) {\n\treqBody := PaymentRequest{\n\t\tAmount: Amount{\n\t\t\tValue: amount.StringFixed(2),\n\t\t\tCurrency: \"RUB\",\n\t\t},\n\t\tCapture: true, // Автоматическое списание\n\t\tConfirmation: Confirmation{\n\t\t\tType: \"redirect\",\n\t\t\tReturnURL: returnURL,\n\t\t},\n\t\tMetadata: map[string]string{\n\t\t\t\"order_id\": orderID.String(),\n\t\t},\n\t\tDescription: description,\n\t}\n\n\tbody, _ := json.Marshal(reqBody)\n\treq, err := http.NewRequestWithContext(ctx, \"POST\", c.baseURL+\"/payments\", bytes.NewReader(body))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Basic Auth\n\tauth := base64.StdEncoding.EncodeToString([]byte(c.shopID + \":\" + c.secretKey))\n\treq.Header.Set(\"Authorization\", \"Basic \"+auth)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\t// Идемпотентность: используем наш order_id как ключ\n\treq.Header.Set(\"Idempotence-Key\", orderID.String())\n\n\tresp, err := c.httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"yookassa error: status %d\", resp.StatusCode)\n\t}\n\n\tvar result PaymentResponse\n\tif err := json.NewDecoder(resp.Body).Decode(&result); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &result, nil\n}\n", - "internal/infrastructure/yookassa/dto.go": "package yookassa\n\ntype Amount struct {\n\tValue string `json:\"value\"`\n\tCurrency string `json:\"currency\"`\n}\n\ntype Confirmation struct {\n\tType string `json:\"type\"`\n\tReturnURL string `json:\"return_url,omitempty\"`\n\tURL string `json:\"confirmation_url,omitempty\"`\n}\n\ntype PaymentRequest struct {\n\tAmount Amount `json:\"amount\"`\n\tCapture bool `json:\"capture\"`\n\tConfirmation Confirmation `json:\"confirmation\"`\n\tMetadata map[string]string `json:\"metadata\"`\n\tDescription string `json:\"description\"`\n}\n\ntype PaymentResponse struct {\n\tID string `json:\"id\"`\n\tStatus string `json:\"status\"`\n\tAmount Amount `json:\"amount\"`\n\tConfirmation Confirmation `json:\"confirmation\"`\n\tMetadata map[string]string `json:\"metadata\"`\n}\n\ntype WebhookEvent struct {\n\tEvent string `json:\"event\"`\n\tType string `json:\"type\"`\n\tObject PaymentResponse `json:\"object\"`\n}\n", - "internal/services/billing/service.go": "// internal/services/billing/service.go\n\npackage billing\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"rmser/internal/domain/account\"\n\t\"rmser/internal/domain/billing\"\n\t\"rmser/internal/infrastructure/yookassa\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/shopspring/decimal\"\n)\n\n// Список доступных тарифов\nvar AvailableTariffs = []billing.Tariff{\n\t{ID: \"pack_10\", Name: \"Пакет 10\", Type: billing.TariffPack, Price: 500, InvoicesCount: 10, DurationDays: 30},\n\t{ID: \"pack_50\", Name: \"Пакет 50\", Type: billing.TariffPack, Price: 1000, InvoicesCount: 50, DurationDays: 180},\n\t{ID: \"pack_100\", Name: \"Пакет 100\", Type: billing.TariffPack, Price: 1500, InvoicesCount: 100, DurationDays: 365},\n\t{ID: \"sub_7\", Name: \"Безлимит 7 дней\", Type: billing.TariffSubscription, Price: 700, InvoicesCount: 1000, DurationDays: 7},\n\t{ID: \"sub_14\", Name: \"Безлимит 14 дней\", Type: billing.TariffSubscription, Price: 1300, InvoicesCount: 1000, DurationDays: 14},\n\t{ID: \"sub_30\", Name: \"Безлимит 30 дней\", Type: billing.TariffSubscription, Price: 2500, InvoicesCount: 1000, DurationDays: 30},\n}\n\n// PaymentNotifier определяет интерфейс для уведомления о успешном платеже\ntype PaymentNotifier interface {\n\tNotifySuccess(userID uuid.UUID, amount float64, newBalance int, serverName string)\n}\n\ntype Service struct {\n\tbillingRepo billing.Repository\n\taccountRepo account.Repository\n\tykClient *yookassa.Client\n\tnotifier PaymentNotifier\n}\n\nfunc (s *Service) SetNotifier(n PaymentNotifier) {\n\ts.notifier = n\n}\n\nfunc NewService(bRepo billing.Repository, aRepo account.Repository, yk *yookassa.Client) *Service {\n\treturn &Service{\n\t\tbillingRepo: bRepo,\n\t\taccountRepo: aRepo,\n\t\tykClient: yk,\n\t}\n}\n\nfunc (s *Service) GetTariffs() []billing.Tariff {\n\treturn AvailableTariffs\n}\n\n// CreateOrder теперь возвращает (*billing.Order, string, error)\nfunc (s *Service) CreateOrder(ctx context.Context, userID uuid.UUID, tariffID string, targetServerURL string, returnURL string) (*billing.Order, string, error) {\n\t// 1. Ищем тариф\n\tvar selectedTariff *billing.Tariff\n\tfor _, t := range AvailableTariffs {\n\t\tif t.ID == tariffID {\n\t\t\tselectedTariff = &t\n\t\t\tbreak\n\t\t}\n\t}\n\tif selectedTariff == nil {\n\t\treturn nil, \"\", errors.New(\"тариф не найден\")\n\t}\n\n\t// 2. Определяем целевой сервер\n\tvar targetServerID uuid.UUID\n\tif targetServerURL != \"\" {\n\t\tsrv, err := s.accountRepo.GetServerByURL(targetServerURL)\n\t\tif err != nil {\n\t\t\treturn nil, \"\", fmt.Errorf(\"сервер не найден: %w\", err)\n\t\t}\n\t\ttargetServerID = srv.ID\n\t} else {\n\t\tsrv, err := s.accountRepo.GetActiveServer(userID)\n\t\tif err != nil || srv == nil {\n\t\t\treturn nil, \"\", errors.New(\"активный сервер не выбран\")\n\t\t}\n\t\ttargetServerID = srv.ID\n\t}\n\n\t// 3. Создаем заказ в БД\n\torder := &billing.Order{\n\t\tID: uuid.New(),\n\t\tUserID: userID,\n\t\tTargetServerID: targetServerID,\n\t\tTariffID: tariffID,\n\t\tAmount: selectedTariff.Price,\n\t\tStatus: billing.StatusPending,\n\t}\n\n\tif err := s.billingRepo.CreateOrder(order); err != nil {\n\t\treturn nil, \"\", err\n\t}\n\n\t// 4. Запрос к ЮКассе\n\tdescription := fmt.Sprintf(\"Оплата тарифа %s\", selectedTariff.Name)\n\tykResp, err := s.ykClient.CreatePayment(ctx, decimal.NewFromFloat(order.Amount), description, order.ID, returnURL)\n\tif err != nil {\n\t\treturn nil, \"\", fmt.Errorf(\"yookassa error: %w\", err)\n\t}\n\n\t// Сохраняем payment_id от ЮКассы в наш заказ\n\tif err := s.billingRepo.UpdateOrderStatus(order.ID, billing.StatusPending, ykResp.ID); err != nil {\n\t\treturn nil, \"\", err\n\t}\n\n\treturn order, ykResp.Confirmation.URL, nil\n}\n\n// ProcessWebhook — вызывается из HTTP хендлера\nfunc (s *Service) ProcessWebhook(ctx context.Context, event yookassa.WebhookEvent) error {\n\tif event.Event != \"payment.succeeded\" {\n\t\treturn nil\n\t}\n\n\torderIDStr := event.Object.Metadata[\"order_id\"]\n\torderID, err := uuid.Parse(orderIDStr)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\torder, err := s.billingRepo.GetOrder(orderID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif order.Status == billing.StatusPaid {\n\t\treturn nil\n\t}\n\n\treturn s.applyOrderToBalance(order, event.Object.ID)\n}\n\n// applyOrderToBalance — внутренняя логика начисления (DRY)\nfunc (s *Service) applyOrderToBalance(order *billing.Order, externalID string) error {\n\tvar tariff *billing.Tariff\n\tfor _, t := range AvailableTariffs {\n\t\tif t.ID == order.TariffID {\n\t\t\ttariff = &t\n\t\t\tbreak\n\t\t}\n\t}\n\n\tserver, err := s.accountRepo.GetServerByID(order.TargetServerID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tnow := time.Now()\n\tvar newPaidUntil time.Time\n\tif server.PaidUntil != nil && server.PaidUntil.After(now) {\n\t\tnewPaidUntil = server.PaidUntil.AddDate(0, 0, tariff.DurationDays)\n\t} else {\n\t\tnewPaidUntil = now.AddDate(0, 0, tariff.DurationDays)\n\t}\n\n\tif err := s.accountRepo.UpdateBalance(server.ID, tariff.InvoicesCount, &newPaidUntil); err != nil {\n\t\treturn err\n\t}\n\n\tif s.notifier != nil {\n\t\t// Мы запускаем в горутине, чтобы не тормозить ответ ЮКассе\n\t\tgo s.notifier.NotifySuccess(order.UserID, order.Amount, server.Balance+tariff.InvoicesCount, server.Name)\n\t}\n\n\treturn s.billingRepo.UpdateOrderStatus(order.ID, billing.StatusPaid, externalID)\n}\n\n// ConfirmOrder — оставляем для совместимости или ручного подтверждения (если нужно)\nfunc (s *Service) ConfirmOrder(orderID uuid.UUID) error {\n\torder, err := s.billingRepo.GetOrder(orderID)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn s.applyOrderToBalance(order, \"manual_confirmation\")\n}\n\n// CanProcessInvoice проверяет возможность отправки накладной\nfunc (s *Service) CanProcessInvoice(serverID uuid.UUID) (bool, error) {\n\tserver, err := s.accountRepo.GetServerByID(serverID)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\tif server.Balance <= 0 {\n\t\treturn false, errors.New(\"недостаточно накладных на балансе\")\n\t}\n\n\tif server.PaidUntil == nil || server.PaidUntil.Before(time.Now()) {\n\t\treturn false, errors.New(\"срок действия услуг истек\")\n\t}\n\n\treturn true, nil\n}\n", - "internal/services/drafts/service.go": "package drafts\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/shopspring/decimal\"\n\t\"go.uber.org/zap\"\n\n\t\"rmser/internal/domain/account\"\n\t\"rmser/internal/domain/catalog\"\n\t\"rmser/internal/domain/drafts\"\n\t\"rmser/internal/domain/invoices\"\n\t\"rmser/internal/domain/ocr\"\n\t\"rmser/internal/domain/photos\"\n\t\"rmser/internal/domain/suppliers\"\n\t\"rmser/internal/infrastructure/rms\"\n\t\"rmser/internal/services/billing\"\n\t\"rmser/pkg/logger\"\n)\n\ntype Service struct {\n\tdraftRepo drafts.Repository\n\tocrRepo ocr.Repository\n\tcatalogRepo catalog.Repository\n\taccountRepo account.Repository\n\tsupplierRepo suppliers.Repository\n\tinvoiceRepo invoices.Repository\n\tphotoRepo photos.Repository\n\trmsFactory *rms.Factory\n\tbillingService *billing.Service\n}\n\nfunc NewService(\n\tdraftRepo drafts.Repository,\n\tocrRepo ocr.Repository,\n\tcatalogRepo catalog.Repository,\n\taccountRepo account.Repository,\n\tsupplierRepo suppliers.Repository,\n\tphotoRepo photos.Repository,\n\tinvoiceRepo invoices.Repository,\n\trmsFactory *rms.Factory,\n\tbillingService *billing.Service,\n) *Service {\n\treturn &Service{\n\t\tdraftRepo: draftRepo,\n\t\tocrRepo: ocrRepo,\n\t\tcatalogRepo: catalogRepo,\n\t\taccountRepo: accountRepo,\n\t\tsupplierRepo: supplierRepo,\n\t\tphotoRepo: photoRepo,\n\t\tinvoiceRepo: invoiceRepo,\n\t\trmsFactory: rmsFactory,\n\t\tbillingService: billingService,\n\t}\n}\n\nfunc (s *Service) checkWriteAccess(userID, serverID uuid.UUID) error {\n\trole, err := s.accountRepo.GetUserRole(userID, serverID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif role == account.RoleOperator {\n\t\treturn errors.New(\"доступ запрещен: оператор не может редактировать данные\")\n\t}\n\treturn nil\n}\n\nfunc (s *Service) GetDraft(draftID, userID uuid.UUID) (*drafts.DraftInvoice, error) {\n\tdraft, err := s.draftRepo.GetByID(draftID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\treturn nil, errors.New(\"нет активного сервера\")\n\t}\n\n\tif draft.RMSServerID != server.ID {\n\t\treturn nil, errors.New(\"черновик не принадлежит активному серверу\")\n\t}\n\n\tif err := s.checkWriteAccess(userID, server.ID); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn draft, nil\n}\n\nfunc (s *Service) GetActiveDrafts(userID uuid.UUID) ([]drafts.DraftInvoice, error) {\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\treturn nil, errors.New(\"активный сервер не выбран\")\n\t}\n\n\tif err := s.checkWriteAccess(userID, server.ID); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn s.draftRepo.GetActive(server.ID)\n}\n\nfunc (s *Service) GetDictionaries(userID uuid.UUID) (map[string]interface{}, error) {\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\treturn nil, fmt.Errorf(\"active server not found\")\n\t}\n\n\tif err := s.checkWriteAccess(userID, server.ID); err != nil {\n\t\treturn nil, err\n\t}\n\n\tstores, _ := s.catalogRepo.GetActiveStores(server.ID)\n\tsuppliersList, _ := s.supplierRepo.GetRankedByUsage(server.ID, 90)\n\n\treturn map[string]interface{}{\n\t\t\"stores\": stores,\n\t\t\"suppliers\": suppliersList,\n\t}, nil\n}\n\nfunc (s *Service) DeleteDraft(id uuid.UUID) (string, error) {\n\tdraft, err := s.draftRepo.GetByID(id)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// Логика статусов\n\tif draft.Status == drafts.StatusCanceled {\n\t\t// Окончательное удаление\n\t\tif err := s.draftRepo.Delete(id); err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\t// ВАЖНО: Разрываем связь с фото (оно становится ORPHAN)\n\t\tif err := s.photoRepo.ClearDraftLinkByDraftID(id); err != nil {\n\t\t\tlogger.Log.Error(\"failed to clear photo draft link\", zap.Error(err))\n\t\t}\n\t\treturn drafts.StatusDeleted, nil\n\t}\n\n\tif draft.Status != drafts.StatusCompleted {\n\t\tdraft.Status = drafts.StatusCanceled\n\t\ts.draftRepo.Update(draft)\n\t\treturn drafts.StatusCanceled, nil\n\t}\n\n\treturn draft.Status, nil\n}\n\nfunc (s *Service) UpdateDraftHeader(id uuid.UUID, storeID *uuid.UUID, supplierID *uuid.UUID, date time.Time, comment string, incomingDocNum string) error {\n\tdraft, err := s.draftRepo.GetByID(id)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif draft.Status == drafts.StatusCompleted {\n\t\treturn errors.New(\"черновик уже отправлен\")\n\t}\n\tdraft.StoreID = storeID\n\tdraft.SupplierID = supplierID\n\tdraft.DateIncoming = &date\n\tdraft.Comment = comment\n\tdraft.IncomingDocumentNumber = incomingDocNum\n\treturn s.draftRepo.Update(draft)\n}\n\nfunc (s *Service) AddItem(draftID uuid.UUID) (*drafts.DraftInvoiceItem, error) {\n\tnewItem := &drafts.DraftInvoiceItem{\n\t\tID: uuid.New(),\n\t\tDraftID: draftID,\n\t\tRawName: \"Новая позиция\",\n\t\tRawAmount: decimal.NewFromFloat(1),\n\t\tRawPrice: decimal.Zero,\n\t\tQuantity: decimal.NewFromFloat(1),\n\t\tPrice: decimal.Zero,\n\t\tSum: decimal.Zero,\n\t\tIsMatched: false,\n\t\tLastEditedField1: drafts.FieldQuantity,\n\t\tLastEditedField2: drafts.FieldPrice,\n\t}\n\n\tif err := s.draftRepo.CreateItem(newItem); err != nil {\n\t\treturn nil, err\n\t}\n\treturn newItem, nil\n}\n\nfunc (s *Service) DeleteItem(draftID, itemID uuid.UUID) (float64, error) {\n\tif err := s.draftRepo.DeleteItem(itemID); err != nil {\n\t\treturn 0, err\n\t}\n\n\tdraft, err := s.draftRepo.GetByID(draftID)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\tvar totalSum decimal.Decimal\n\tfor _, item := range draft.Items {\n\t\tif !item.Sum.IsZero() {\n\t\t\ttotalSum = totalSum.Add(item.Sum)\n\t\t} else {\n\t\t\ttotalSum = totalSum.Add(item.Quantity.Mul(item.Price))\n\t\t}\n\t}\n\n\tsumFloat, _ := totalSum.Float64()\n\treturn sumFloat, nil\n}\n\n// RecalculateItemFields - логика пересчета Qty/Price/Sum\nfunc (s *Service) RecalculateItemFields(item *drafts.DraftInvoiceItem, editedField drafts.EditedField) {\n\tif item.LastEditedField1 != editedField {\n\t\titem.LastEditedField2 = item.LastEditedField1\n\t\titem.LastEditedField1 = editedField\n\t}\n\n\tfieldsToKeep := map[drafts.EditedField]bool{\n\t\titem.LastEditedField1: true,\n\t\titem.LastEditedField2: true,\n\t}\n\n\tvar fieldToRecalc drafts.EditedField\n\tfieldToRecalc = drafts.FieldSum // Default fallback\n\n\tfor _, f := range []drafts.EditedField{drafts.FieldQuantity, drafts.FieldPrice, drafts.FieldSum} {\n\t\tif !fieldsToKeep[f] {\n\t\t\tfieldToRecalc = f\n\t\t\tbreak\n\t\t}\n\t}\n\n\tswitch fieldToRecalc {\n\tcase drafts.FieldQuantity:\n\t\tif !item.Price.IsZero() {\n\t\t\titem.Quantity = item.Sum.Div(item.Price)\n\t\t} else {\n\t\t\titem.Quantity = decimal.Zero\n\t\t}\n\tcase drafts.FieldPrice:\n\t\tif !item.Quantity.IsZero() {\n\t\t\titem.Price = item.Sum.Div(item.Quantity)\n\t\t} else {\n\t\t\titem.Price = decimal.Zero\n\t\t}\n\tcase drafts.FieldSum:\n\t\titem.Sum = item.Quantity.Mul(item.Price)\n\t}\n}\n\n// UpdateItem обновлен для поддержки динамического пересчета\nfunc (s *Service) UpdateItem(draftID, itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price, sum decimal.Decimal, editedField string) error {\n\tdraft, err := s.draftRepo.GetByID(draftID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tcurrentItem, err := s.draftRepo.GetItemByID(itemID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif productID != nil {\n\t\tcurrentItem.ProductID = productID\n\t\tcurrentItem.IsMatched = true\n\t}\n\n\tif containerID != nil {\n\t\t// Если пришел UUID.Nil, значит сброс\n\t\tif *containerID == uuid.Nil {\n\t\t\tcurrentItem.ContainerID = nil\n\t\t} else {\n\t\t\tcurrentItem.ContainerID = containerID\n\t\t}\n\t}\n\n\tfield := drafts.EditedField(editedField)\n\tswitch field {\n\tcase drafts.FieldQuantity:\n\t\tcurrentItem.Quantity = qty\n\tcase drafts.FieldPrice:\n\t\tcurrentItem.Price = price\n\tcase drafts.FieldSum:\n\t\tcurrentItem.Sum = sum\n\t}\n\n\ts.RecalculateItemFields(currentItem, field)\n\n\tif draft.Status == drafts.StatusCanceled {\n\t\tdraft.Status = drafts.StatusReadyToVerify\n\t\ts.draftRepo.Update(draft)\n\t}\n\n\tupdates := map[string]interface{}{\n\t\t\"product_id\": currentItem.ProductID,\n\t\t\"container_id\": currentItem.ContainerID,\n\t\t\"quantity\": currentItem.Quantity,\n\t\t\"price\": currentItem.Price,\n\t\t\"sum\": currentItem.Sum,\n\t\t\"last_edited_field1\": currentItem.LastEditedField1,\n\t\t\"last_edited_field2\": currentItem.LastEditedField2,\n\t\t\"is_matched\": currentItem.IsMatched,\n\t}\n\n\treturn s.draftRepo.UpdateItem(itemID, updates)\n}\n\nfunc (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) {\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"active server not found: %w\", err)\n\t}\n\n\tif err := s.checkWriteAccess(userID, server.ID); err != nil {\n\t\treturn \"\", err\n\t}\n\n\tif can, err := s.billingService.CanProcessInvoice(server.ID); !can {\n\t\treturn \"\", fmt.Errorf(\"ошибка биллинга: %w\", err)\n\t}\n\n\tdraft, err := s.draftRepo.GetByID(draftID)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tif draft.RMSServerID != server.ID {\n\t\treturn \"\", errors.New(\"черновик принадлежит другому серверу\")\n\t}\n\n\tif draft.Status == drafts.StatusCompleted {\n\t\treturn \"\", errors.New(\"накладная уже отправлена\")\n\t}\n\n\tclient, err := s.rmsFactory.GetClientForUser(userID)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\ttargetStatus := \"NEW\"\n\tif server.AutoProcess {\n\t\ttargetStatus = \"PROCESSED\"\n\t}\n\n\tinv := invoices.Invoice{\n\t\tID: uuid.Nil,\n\t\tDocumentNumber: draft.DocumentNumber,\n\t\tDateIncoming: *draft.DateIncoming,\n\t\tSupplierID: *draft.SupplierID,\n\t\tDefaultStoreID: *draft.StoreID,\n\t\tStatus: targetStatus,\n\t\tComment: draft.Comment,\n\t\tIncomingDocumentNumber: draft.IncomingDocumentNumber,\n\t\tItems: make([]invoices.InvoiceItem, 0, len(draft.Items)),\n\t}\n\n\tfor _, dItem := range draft.Items {\n\t\tif dItem.ProductID == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tsum := dItem.Sum\n\t\tif sum.IsZero() {\n\t\t\tsum = dItem.Quantity.Mul(dItem.Price)\n\t\t}\n\n\t\tamountToSend := dItem.Quantity\n\t\tpriceToSend := dItem.Price\n\n\t\tif dItem.ContainerID != nil && *dItem.ContainerID != uuid.Nil {\n\t\t\tif dItem.Container != nil {\n\t\t\t\tif !dItem.Container.Count.IsZero() {\n\t\t\t\t\tamountToSend = dItem.Quantity.Mul(dItem.Container.Count)\n\t\t\t\t\tpriceToSend = dItem.Price.Div(dItem.Container.Count)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tlogger.Log.Warn(\"Container struct is nil for item with ContainerID\",\n\t\t\t\t\tzap.String(\"item_id\", dItem.ID.String()),\n\t\t\t\t\tzap.String(\"container_id\", dItem.ContainerID.String()))\n\t\t\t}\n\t\t}\n\n\t\tinvItem := invoices.InvoiceItem{\n\t\t\tProductID: *dItem.ProductID,\n\t\t\tAmount: amountToSend,\n\t\t\tPrice: priceToSend,\n\t\t\tSum: sum,\n\t\t\tContainerID: dItem.ContainerID,\n\t\t}\n\t\tinv.Items = append(inv.Items, invItem)\n\t}\n\n\tif len(inv.Items) == 0 {\n\t\treturn \"\", errors.New(\"нет распознанных позиций для отправки\")\n\t}\n\n\tdocNum, err := client.CreateIncomingInvoice(inv)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tinvoices, err := client.FetchInvoices(*draft.DateIncoming, *draft.DateIncoming)\n\tif err != nil {\n\t\tlogger.Log.Warn(\"Не удалось получить список накладных для поиска UUID\", zap.Error(err), zap.Time(\"date\", *draft.DateIncoming))\n\t} else {\n\t\tfound := false\n\t\tfor _, invoice := range invoices {\n\t\t\tif invoice.DocumentNumber == docNum {\n\t\t\t\tdraft.RMSInvoiceID = &invoice.ID\n\t\t\t\tfound = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif !found {\n\t\t\tlogger.Log.Warn(\"UUID созданной накладной не найден\", zap.String(\"document_number\", docNum), zap.Time(\"date\", *draft.DateIncoming))\n\t\t}\n\t}\n\n\tdraft.Status = drafts.StatusCompleted\n\ts.draftRepo.Update(draft)\n\n\tif err := s.accountRepo.DecrementBalance(server.ID); err != nil {\n\t\tlogger.Log.Error(\"Billing decrement failed\", zap.Error(err), zap.String(\"server_id\", server.ID.String()))\n\t}\n\n\tif err := s.accountRepo.IncrementInvoiceCount(server.ID); err != nil {\n\t\tlogger.Log.Error(\"Billing increment failed\", zap.Error(err))\n\t}\n\n\tgo s.learnFromDraft(draft, server.ID)\n\n\treturn docNum, nil\n}\n\nfunc (s *Service) learnFromDraft(draft *drafts.DraftInvoice, serverID uuid.UUID) {\n\tfor _, item := range draft.Items {\n\t\tif item.RawName != \"\" && item.ProductID != nil {\n\t\t\tqty := decimal.NewFromFloat(1.0)\n\t\t\terr := s.ocrRepo.SaveMatch(serverID, item.RawName, *item.ProductID, qty, item.ContainerID)\n\t\t\tif err != nil {\n\t\t\t\tlogger.Log.Warn(\"Failed to learn match\", zap.Error(err))\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (s *Service) CreateProductContainer(userID uuid.UUID, productID uuid.UUID, name string, count decimal.Decimal) (uuid.UUID, error) {\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\treturn uuid.Nil, errors.New(\"no active server\")\n\t}\n\tif err := s.checkWriteAccess(userID, server.ID); err != nil {\n\t\treturn uuid.Nil, err\n\t}\n\n\tclient, err := s.rmsFactory.GetClientForUser(userID)\n\tif err != nil {\n\t\treturn uuid.Nil, err\n\t}\n\n\tfullProduct, err := client.GetProductByID(productID)\n\tif err != nil {\n\t\treturn uuid.Nil, fmt.Errorf(\"error fetching product: %w\", err)\n\t}\n\n\ttargetCount, _ := count.Float64()\n\tfor _, c := range fullProduct.Containers {\n\t\tif !c.Deleted && (c.Name == name || (c.Count == targetCount)) {\n\t\t\tif c.ID != nil && *c.ID != \"\" {\n\t\t\t\treturn uuid.Parse(*c.ID)\n\t\t\t}\n\t\t}\n\t}\n\n\tmaxNum := 0\n\tfor _, c := range fullProduct.Containers {\n\t\tif n, err := strconv.Atoi(c.Num); err == nil {\n\t\t\tif n > maxNum {\n\t\t\t\tmaxNum = n\n\t\t\t}\n\t\t}\n\t}\n\tnextNum := strconv.Itoa(maxNum + 1)\n\n\tnewContainerDTO := rms.ContainerFullDTO{\n\t\tID: nil,\n\t\tNum: nextNum,\n\t\tName: name,\n\t\tCount: targetCount,\n\t\tUseInFront: true,\n\t\tDeleted: false,\n\t}\n\tfullProduct.Containers = append(fullProduct.Containers, newContainerDTO)\n\n\tupdatedProduct, err := client.UpdateProduct(*fullProduct)\n\tif err != nil {\n\t\treturn uuid.Nil, fmt.Errorf(\"error updating product: %w\", err)\n\t}\n\n\tvar createdID uuid.UUID\n\tfound := false\n\tfor _, c := range updatedProduct.Containers {\n\t\tif c.Name == name && c.Count == targetCount && !c.Deleted {\n\t\t\tif c.ID != nil {\n\t\t\t\tcreatedID, err = uuid.Parse(*c.ID)\n\t\t\t\tif err == nil {\n\t\t\t\t\tfound = true\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tif !found {\n\t\treturn uuid.Nil, errors.New(\"container created but id not found\")\n\t}\n\n\tnewLocalContainer := catalog.ProductContainer{\n\t\tID: createdID,\n\t\tRMSServerID: server.ID,\n\t\tProductID: productID,\n\t\tName: name,\n\t\tCount: count,\n\t}\n\ts.catalogRepo.SaveContainer(newLocalContainer)\n\n\treturn createdID, nil\n}\n\ntype UnifiedInvoiceDTO struct {\n\tID uuid.UUID `json:\"id\"`\n\tType string `json:\"type\"`\n\tDocumentNumber string `json:\"document_number\"`\n\tIncomingNumber string `json:\"incoming_number\"`\n\tDateIncoming time.Time `json:\"date_incoming\"`\n\tStatus string `json:\"status\"`\n\tTotalSum float64 `json:\"total_sum\"`\n\tStoreName string `json:\"store_name\"`\n\tItemsCount int `json:\"items_count\"`\n\tCreatedAt time.Time `json:\"created_at\"`\n\tIsAppCreated bool `json:\"is_app_created\"`\n\tPhotoURL string `json:\"photo_url\"`\n\tItemsPreview string `json:\"items_preview\"`\n}\n\nfunc (s *Service) GetUnifiedList(userID uuid.UUID, from, to time.Time) ([]UnifiedInvoiceDTO, error) {\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\treturn nil, errors.New(\"активный сервер не выбран\")\n\t}\n\n\tdraftsList, err := s.draftRepo.GetActive(server.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tinvoicesList, err := s.invoiceRepo.GetByPeriod(server.ID, from, to)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tphotoMap, err := s.draftRepo.GetRMSInvoiceIDToPhotoURLMap(server.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tresult := make([]UnifiedInvoiceDTO, 0, len(draftsList)+len(invoicesList))\n\n\tfor _, d := range draftsList {\n\t\tvar sum decimal.Decimal\n\t\tfor _, it := range d.Items {\n\t\t\tif !it.Sum.IsZero() {\n\t\t\t\tsum = sum.Add(it.Sum)\n\t\t\t} else {\n\t\t\t\tsum = sum.Add(it.Quantity.Mul(it.Price))\n\t\t\t}\n\t\t}\n\n\t\tval, _ := sum.Float64()\n\t\tdate := time.Now()\n\t\tif d.DateIncoming != nil {\n\t\t\tdate = *d.DateIncoming\n\t\t}\n\n\t\tvar itemsPreview string\n\t\tif len(d.Items) > 0 {\n\t\t\tnames := make([]string, 0, 3)\n\t\t\tfor i, it := range d.Items {\n\t\t\t\tif i >= 3 {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tnames = append(names, it.RawName)\n\t\t\t}\n\t\t\titemsPreview = strings.Join(names, \", \")\n\t\t}\n\n\t\tresult = append(result, UnifiedInvoiceDTO{\n\t\t\tID: d.ID,\n\t\t\tType: \"DRAFT\",\n\t\t\tDocumentNumber: d.DocumentNumber,\n\t\t\tIncomingNumber: \"\",\n\t\t\tDateIncoming: date,\n\t\t\tStatus: d.Status,\n\t\t\tTotalSum: val,\n\t\t\tStoreName: \"\",\n\t\t\tItemsCount: len(d.Items),\n\t\t\tCreatedAt: d.CreatedAt,\n\t\t\tIsAppCreated: true,\n\t\t\tPhotoURL: d.SenderPhotoURL,\n\t\t\tItemsPreview: itemsPreview,\n\t\t})\n\t}\n\n\tfor _, inv := range invoicesList {\n\t\tvar sum decimal.Decimal\n\t\tfor _, it := range inv.Items {\n\t\t\tsum = sum.Add(it.Sum)\n\t\t}\n\t\tval, _ := sum.Float64()\n\n\t\tisAppCreated := false\n\t\tphotoURL := \"\"\n\t\tif url, exists := photoMap[inv.ID]; exists {\n\t\t\tisAppCreated = true\n\t\t\tphotoURL = url\n\t\t}\n\n\t\tvar itemsPreview string\n\t\tif len(inv.Items) > 0 {\n\t\t\tnames := make([]string, 0, 3)\n\t\t\tfor i, it := range inv.Items {\n\t\t\t\tif i >= 3 {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tif it.Product.Name != \"\" {\n\t\t\t\t\tnames = append(names, it.Product.Name)\n\t\t\t\t}\n\t\t\t}\n\t\t\titemsPreview = strings.Join(names, \", \")\n\t\t}\n\n\t\tresult = append(result, UnifiedInvoiceDTO{\n\t\t\tID: inv.ID,\n\t\t\tType: \"SYNCED\",\n\t\t\tDocumentNumber: inv.DocumentNumber,\n\t\t\tIncomingNumber: inv.IncomingDocumentNumber,\n\t\t\tDateIncoming: inv.DateIncoming,\n\t\t\tStatus: inv.Status,\n\t\t\tTotalSum: val,\n\t\t\tItemsCount: len(inv.Items),\n\t\t\tCreatedAt: inv.CreatedAt,\n\t\t\tIsAppCreated: isAppCreated,\n\t\t\tPhotoURL: photoURL,\n\t\t\tItemsPreview: itemsPreview,\n\t\t})\n\t}\n\n\treturn result, nil\n}\n\nfunc (s *Service) GetInvoiceDetails(invoiceID, userID uuid.UUID) (*invoices.Invoice, string, error) {\n\tinv, err := s.invoiceRepo.GetByID(invoiceID)\n\tif err != nil {\n\t\treturn nil, \"\", err\n\t}\n\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\treturn nil, \"\", errors.New(\"нет активного сервера\")\n\t}\n\n\tif inv.RMSServerID != server.ID {\n\t\treturn nil, \"\", errors.New(\"накладная не принадлежит активному серверу\")\n\t}\n\n\tdraft, err := s.draftRepo.GetByRMSInvoiceID(invoiceID)\n\tif err != nil {\n\t\treturn nil, \"\", err\n\t}\n\n\tphotoURL := \"\"\n\tif draft != nil {\n\t\tphotoURL = draft.SenderPhotoURL\n\t}\n\n\treturn inv, photoURL, nil\n}\n", - "internal/services/invoices/service.go": "package invoices\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/shopspring/decimal\"\n\t\"go.uber.org/zap\"\n\n\t\"rmser/internal/domain/drafts\"\n\tinvDomain \"rmser/internal/domain/invoices\"\n\t\"rmser/internal/domain/suppliers\"\n\t\"rmser/internal/infrastructure/rms\"\n\t\"rmser/pkg/logger\"\n)\n\ntype Service struct {\n\trepo invDomain.Repository\n\tdraftsRepo drafts.Repository\n\tsupplierRepo suppliers.Repository\n\trmsFactory *rms.Factory\n\t// Здесь можно добавить репозитории каталога и контрагентов для валидации,\n\t// но для краткости пока опустим глубокую валидацию.\n}\n\nfunc NewService(repo invDomain.Repository, draftsRepo drafts.Repository, supplierRepo suppliers.Repository, rmsFactory *rms.Factory) *Service {\n\treturn &Service{\n\t\trepo: repo,\n\t\tdraftsRepo: draftsRepo,\n\t\tsupplierRepo: supplierRepo,\n\t\trmsFactory: rmsFactory,\n\t}\n}\n\n// CreateRequestDTO - структура входящего JSON запроса от фронта/OCR\ntype CreateRequestDTO struct {\n\tDocumentNumber string `json:\"document_number\"`\n\tDateIncoming string `json:\"date_incoming\"` // YYYY-MM-DD\n\tSupplierID uuid.UUID `json:\"supplier_id\"`\n\tStoreID uuid.UUID `json:\"store_id\"`\n\tItems []struct {\n\t\tProductID uuid.UUID `json:\"product_id\"`\n\t\tAmount decimal.Decimal `json:\"amount\"`\n\t\tPrice decimal.Decimal `json:\"price\"`\n\t} `json:\"items\"`\n}\n\n// SendInvoiceToRMS валидирует DTO, собирает доменную модель и отправляет в RMS\nfunc (s *Service) SendInvoiceToRMS(req CreateRequestDTO, userID uuid.UUID) (string, error) {\n\t// 1. Базовая валидация\n\tif len(req.Items) == 0 {\n\t\treturn \"\", fmt.Errorf(\"список товаров пуст\")\n\t}\n\n\tdateInc, err := time.Parse(\"2006-01-02\", req.DateIncoming)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"неверный формат даты (ожидается YYYY-MM-DD): %v\", err)\n\t}\n\n\t// 2. Сборка доменной модели\n\tinv := invDomain.Invoice{\n\t\tID: uuid.Nil, // Новый документ\n\t\tDocumentNumber: req.DocumentNumber,\n\t\tDateIncoming: dateInc,\n\t\tSupplierID: req.SupplierID,\n\t\tDefaultStoreID: req.StoreID,\n\t\tStatus: \"NEW\",\n\t\tItems: make([]invDomain.InvoiceItem, 0, len(req.Items)),\n\t}\n\n\tfor _, itemDTO := range req.Items {\n\t\tsum := itemDTO.Amount.Mul(itemDTO.Price) // Пересчитываем сумму\n\n\t\tinv.Items = append(inv.Items, invDomain.InvoiceItem{\n\t\t\tProductID: itemDTO.ProductID,\n\t\t\tAmount: itemDTO.Amount,\n\t\t\tPrice: itemDTO.Price,\n\t\t\tSum: sum,\n\t\t})\n\t}\n\n\t// 3. Получение клиента RMS\n\tclient, err := s.rmsFactory.GetClientForUser(userID)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"ошибка получения клиента RMS: %w\", err)\n\t}\n\n\t// 4. Отправка через клиент\n\tlogger.Log.Info(\"Отправка накладной в RMS\",\n\t\tzap.String(\"supplier\", req.SupplierID.String()),\n\t\tzap.Int(\"items_count\", len(inv.Items)))\n\n\tdocNum, err := client.CreateIncomingInvoice(inv)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn docNum, nil\n}\n\n// InvoiceDetailsDTO - DTO для ответа на запрос деталей накладной\ntype InvoiceDetailsDTO struct {\n\tID uuid.UUID `json:\"id\"`\n\tNumber string `json:\"number\"`\n\tDate string `json:\"date\"`\n\tStatus string `json:\"status\"`\n\tSupplier struct {\n\t\tID uuid.UUID `json:\"id\"`\n\t\tName string `json:\"name\"`\n\t} `json:\"supplier\"`\n\tItems []struct {\n\t\tName string `json:\"name\"`\n\t\tQuantity float64 `json:\"quantity\"`\n\t\tPrice float64 `json:\"price\"`\n\t\tTotal float64 `json:\"total\"`\n\t} `json:\"items\"`\n\tPhotoURL *string `json:\"photo_url\"`\n}\n\n// GetInvoice возвращает детали синхронизированной накладной по ID\nfunc (s *Service) GetInvoice(id uuid.UUID) (*InvoiceDetailsDTO, error) {\n\t// 1. Получить накладную из репозитория\n\tinv, err := s.repo.GetByID(id)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"ошибка получения накладной: %w\", err)\n\t}\n\n\t// 2. Получить поставщика\n\tsupplier, err := s.supplierRepo.GetByID(inv.SupplierID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"ошибка получения поставщика: %w\", err)\n\t}\n\n\t// 3. Проверить, есть ли draft с photo_url\n\tvar photoURL *string\n\tdraft, err := s.draftsRepo.GetByRMSInvoiceID(inv.ID)\n\tif err == nil && draft != nil {\n\t\tphotoURL = &draft.SenderPhotoURL\n\t}\n\n\t// 4. Собрать DTO\n\tdto := &InvoiceDetailsDTO{\n\t\tID: inv.ID,\n\t\tNumber: inv.DocumentNumber,\n\t\tDate: inv.DateIncoming.Format(\"2006-01-02\"),\n\t\tStatus: \"COMPLETED\", // Для синхронизированных накладных статус всегда COMPLETED\n\t\tItems: make([]struct {\n\t\t\tName string `json:\"name\"`\n\t\t\tQuantity float64 `json:\"quantity\"`\n\t\t\tPrice float64 `json:\"price\"`\n\t\t\tTotal float64 `json:\"total\"`\n\t\t}, len(inv.Items)),\n\t\tPhotoURL: photoURL,\n\t}\n\n\tdto.Supplier.ID = supplier.ID\n\tdto.Supplier.Name = supplier.Name\n\n\tfor i, item := range inv.Items {\n\t\tdto.Items[i].Name = item.Product.Name\n\t\tdto.Items[i].Quantity, _ = item.Amount.Float64()\n\t\tdto.Items[i].Price, _ = item.Price.Float64()\n\t\tdto.Items[i].Total, _ = item.Sum.Float64()\n\t}\n\n\treturn dto, nil\n}\n", - "internal/services/ocr/service.go": "package ocr\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/shopspring/decimal\"\n\n\t\"rmser/internal/domain/account\"\n\t\"rmser/internal/domain/catalog\"\n\t\"rmser/internal/domain/drafts\"\n\t\"rmser/internal/domain/ocr\"\n\t\"rmser/internal/domain/photos\"\n\t\"rmser/internal/infrastructure/ocr_client\"\n)\n\n// DevNotifier - интерфейс для уведомления разработчиков\ntype DevNotifier interface {\n\tNotifyDevs(devIDs []int64, photoPath string, serverName string, serverID string)\n}\n\ntype Service struct {\n\tocrRepo ocr.Repository\n\tcatalogRepo catalog.Repository\n\tdraftRepo drafts.Repository\n\taccountRepo account.Repository\n\tphotoRepo photos.Repository\n\tpyClient *ocr_client.Client\n\tstoragePath string\n\tnotifier DevNotifier\n\tdevIDs []int64\n}\n\nfunc NewService(\n\tocrRepo ocr.Repository,\n\tcatalogRepo catalog.Repository,\n\tdraftRepo drafts.Repository,\n\taccountRepo account.Repository,\n\tphotoRepo photos.Repository,\n\tpyClient *ocr_client.Client,\n\tstoragePath string,\n) *Service {\n\treturn &Service{\n\t\tocrRepo: ocrRepo,\n\t\tcatalogRepo: catalogRepo,\n\t\tdraftRepo: draftRepo,\n\t\taccountRepo: accountRepo,\n\t\tphotoRepo: photoRepo,\n\t\tpyClient: pyClient,\n\t\tstoragePath: storagePath,\n\t}\n}\n\n// SetNotifier - устанавливает notifier для уведомлений разработчиков\nfunc (s *Service) SetNotifier(n DevNotifier) {\n\ts.notifier = n\n}\n\n// SetDevIDs - устанавливает список ID разработчиков для уведомлений\nfunc (s *Service) SetDevIDs(ids []int64) {\n\ts.devIDs = ids\n}\n\n// checkWriteAccess - вспомогательный метод проверки прав\nfunc (s *Service) checkWriteAccess(userID, serverID uuid.UUID) error {\n\trole, err := s.accountRepo.GetUserRole(userID, serverID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif role == account.RoleOperator {\n\t\treturn errors.New(\"access denied: operators cannot modify data\")\n\t}\n\treturn nil\n}\n\n// ProcessReceiptImage - Доступно всем (включая Операторов)\nfunc (s *Service) ProcessReceiptImage(ctx context.Context, userID uuid.UUID, imgData []byte) (*drafts.DraftInvoice, error) {\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\treturn nil, fmt.Errorf(\"no active server for user\")\n\t}\n\tserverID := server.ID\n\n\t// 1. Создаем ID для фото и черновика\n\tphotoID := uuid.New()\n\tdraftID := uuid.New()\n\n\tfileName := fmt.Sprintf(\"receipt_%s.jpg\", photoID.String())\n\tfilePath := filepath.Join(s.storagePath, serverID.String(), fileName)\n\n\t// 2. Создаем директорию если не существует\n\tif err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create directory: %w\", err)\n\t}\n\n\t// 3. Сохраняем файл\n\tif err := os.WriteFile(filePath, imgData, 0644); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to save image: %w\", err)\n\t}\n\tfileURL := fmt.Sprintf(\"/uploads/%s/%s\", serverID.String(), fileName)\n\n\t// 4. Создаем запись ReceiptPhoto\n\tphoto := &photos.ReceiptPhoto{\n\t\tID: photoID,\n\t\tRMSServerID: serverID,\n\t\tUploadedBy: userID,\n\t\tFilePath: filePath,\n\t\tFileURL: fileURL,\n\t\tFileName: fileName,\n\t\tFileSize: int64(len(imgData)),\n\t\tDraftID: &draftID, // Сразу связываем с будущим черновиком\n\t\tCreatedAt: time.Now(),\n\t}\n\tif err := s.photoRepo.Create(photo); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create photo record: %w\", err)\n\t}\n\n\t// 5. Создаем черновик\n\tdraft := &drafts.DraftInvoice{\n\t\tID: draftID,\n\t\tUserID: userID,\n\t\tRMSServerID: serverID,\n\t\tStatus: drafts.StatusProcessing,\n\t\tStoreID: server.DefaultStoreID,\n\t\tSenderPhotoURL: fileURL, // Оставляем для совместимости, но теперь есть ReceiptPhoto\n\t}\n\n\tif err := s.draftRepo.Create(draft); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create draft: %w\", err)\n\t}\n\n\t// Уведомляем разработчиков если devIDs заданы\n\tif len(s.devIDs) > 0 && s.notifier != nil {\n\t\ts.notifier.NotifyDevs(s.devIDs, filePath, server.Name, serverID.String())\n\t}\n\n\t// 6. Отправляем в Python OCR\n\trawResult, err := s.pyClient.ProcessImage(ctx, imgData, \"receipt.jpg\")\n\tif err != nil {\n\t\tdraft.Status = drafts.StatusError\n\t\t_ = s.draftRepo.Update(draft)\n\t\treturn nil, fmt.Errorf(\"python ocr error: %w\", err)\n\t}\n\n\t// 6. Матчинг и сохранение позиций\n\tvar draftItems []drafts.DraftInvoiceItem\n\tfor _, rawItem := range rawResult.Items {\n\t\titem := drafts.DraftInvoiceItem{\n\t\t\tDraftID: draft.ID,\n\t\t\tRawName: rawItem.RawName,\n\t\t\tRawAmount: decimal.NewFromFloat(rawItem.Amount),\n\t\t\tRawPrice: decimal.NewFromFloat(rawItem.Price),\n\t\t\tQuantity: decimal.NewFromFloat(rawItem.Amount),\n\t\t\tPrice: decimal.NewFromFloat(rawItem.Price),\n\t\t\tSum: decimal.NewFromFloat(rawItem.Sum),\n\t\t}\n\n\t\tmatch, _ := s.ocrRepo.FindMatch(serverID, rawItem.RawName)\n\t\tif match != nil {\n\t\t\titem.IsMatched = true\n\t\t\titem.ProductID = &match.ProductID\n\t\t\titem.ContainerID = match.ContainerID\n\t\t} else {\n\t\t\ts.ocrRepo.UpsertUnmatched(serverID, rawItem.RawName)\n\t\t}\n\t\tdraftItems = append(draftItems, item)\n\t}\n\n\tdraft.Status = drafts.StatusReadyToVerify\n\ts.draftRepo.Update(draft)\n\ts.draftRepo.CreateItems(draftItems)\n\tdraft.Items = draftItems\n\n\treturn draft, nil\n}\n\n// Добавить структуры в конец файла\ntype ContainerForIndex struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tCount float64 `json:\"count\"`\n}\n\ntype ProductForIndex struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tCode string `json:\"code\"`\n\tMeasureUnit string `json:\"measure_unit\"`\n\tContainers []ContainerForIndex `json:\"containers\"`\n}\n\n// GetCatalogForIndexing - Только для админов/владельцев (т.к. используется для ручного матчинга)\nfunc (s *Service) GetCatalogForIndexing(userID uuid.UUID) ([]ProductForIndex, error) {\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\treturn nil, fmt.Errorf(\"no server\")\n\t}\n\tif err := s.checkWriteAccess(userID, server.ID); err != nil {\n\t\treturn nil, err\n\t}\n\n\tproducts, err := s.catalogRepo.GetActiveGoods(server.ID, server.RootGroupGUID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tresult := make([]ProductForIndex, 0, len(products))\n\tfor _, p := range products {\n\t\tuom := \"\"\n\t\tif p.MainUnit != nil {\n\t\t\tuom = p.MainUnit.Name\n\t\t}\n\n\t\tvar conts []ContainerForIndex\n\t\tfor _, c := range p.Containers {\n\t\t\tcnt, _ := c.Count.Float64()\n\t\t\tconts = append(conts, ContainerForIndex{\n\t\t\t\tID: c.ID.String(),\n\t\t\t\tName: c.Name,\n\t\t\t\tCount: cnt,\n\t\t\t})\n\t\t}\n\n\t\tresult = append(result, ProductForIndex{\n\t\t\tID: p.ID.String(),\n\t\t\tName: p.Name,\n\t\t\tCode: p.Code,\n\t\t\tMeasureUnit: uom,\n\t\t\tContainers: conts,\n\t\t})\n\t}\n\treturn result, nil\n}\n\nfunc (s *Service) SearchProducts(userID uuid.UUID, query string) ([]catalog.Product, error) {\n\tif len(query) < 2 {\n\t\treturn []catalog.Product{}, nil\n\t}\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\treturn nil, fmt.Errorf(\"no server\")\n\t}\n\t// Поиск нужен для матчинга, значит тоже защищаем\n\tif err := s.checkWriteAccess(userID, server.ID); err != nil {\n\t\treturn nil, err\n\t}\n\treturn s.catalogRepo.Search(server.ID, query, server.RootGroupGUID)\n}\n\nfunc (s *Service) SaveMapping(userID uuid.UUID, rawName string, productID uuid.UUID, quantity decimal.Decimal, containerID *uuid.UUID) error {\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\treturn fmt.Errorf(\"no server\")\n\t}\n\tif err := s.checkWriteAccess(userID, server.ID); err != nil {\n\t\treturn err\n\t}\n\treturn s.ocrRepo.SaveMatch(server.ID, rawName, productID, quantity, containerID)\n}\n\nfunc (s *Service) DeleteMatch(userID uuid.UUID, rawName string) error {\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\treturn fmt.Errorf(\"no server\")\n\t}\n\tif err := s.checkWriteAccess(userID, server.ID); err != nil {\n\t\treturn err\n\t}\n\treturn s.ocrRepo.DeleteMatch(server.ID, rawName)\n}\n\nfunc (s *Service) GetKnownMatches(userID uuid.UUID) ([]ocr.ProductMatch, error) {\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\treturn nil, fmt.Errorf(\"no server\")\n\t}\n\tif err := s.checkWriteAccess(userID, server.ID); err != nil {\n\t\treturn nil, err\n\t}\n\treturn s.ocrRepo.GetAllMatches(server.ID)\n}\n\nfunc (s *Service) GetUnmatchedItems(userID uuid.UUID) ([]ocr.UnmatchedItem, error) {\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\treturn nil, fmt.Errorf(\"no server\")\n\t}\n\tif err := s.checkWriteAccess(userID, server.ID); err != nil {\n\t\treturn nil, err\n\t}\n\treturn s.ocrRepo.GetTopUnmatched(server.ID, 50)\n}\n\nfunc (s *Service) DiscardUnmatched(userID uuid.UUID, rawName string) error {\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\treturn fmt.Errorf(\"no server\")\n\t}\n\tif err := s.checkWriteAccess(userID, server.ID); err != nil {\n\t\treturn err\n\t}\n\treturn s.ocrRepo.DeleteUnmatched(server.ID, rawName)\n}\n", - "internal/services/photos/service.go": "package photos\n\nimport (\n\t\"errors\"\n\t\"os\"\n\n\t\"github.com/google/uuid\"\n\n\t\"rmser/internal/domain/account\"\n\t\"rmser/internal/domain/drafts\"\n\t\"rmser/internal/domain/photos\"\n)\n\nvar (\n\tErrPhotoHasInvoice = errors.New(\"нельзя удалить фото: есть созданная накладная\")\n\tErrPhotoHasDraft = errors.New(\"фото связано с черновиком\")\n)\n\ntype Service struct {\n\tphotoRepo photos.Repository\n\tdraftRepo drafts.Repository\n\taccountRepo account.Repository\n}\n\nfunc NewService(photoRepo photos.Repository, draftRepo drafts.Repository, accountRepo account.Repository) *Service {\n\treturn &Service{\n\t\tphotoRepo: photoRepo,\n\t\tdraftRepo: draftRepo,\n\t\taccountRepo: accountRepo,\n\t}\n}\n\n// DTO для ответа\ntype PhotoWithStatus struct {\n\tphotos.ReceiptPhoto\n\tStatus photos.PhotoStatus `json:\"status\"`\n\tCanDelete bool `json:\"can_delete\"`\n\tCanRegenerate bool `json:\"can_regenerate\"`\n}\n\nfunc (s *Service) GetPhotosForServer(userID uuid.UUID, page, limit int) ([]PhotoWithStatus, int64, error) {\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\treturn nil, 0, errors.New(\"active server not found\")\n\t}\n\n\titems, total, err := s.photoRepo.GetByServerID(server.ID, page, limit)\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\tresult := make([]PhotoWithStatus, len(items))\n\tfor i, photo := range items {\n\t\tstatus := photos.PhotoStatusOrphan\n\t\tif photo.InvoiceID != nil {\n\t\t\tstatus = photos.PhotoStatusHasInvoice\n\t\t} else if photo.DraftID != nil {\n\t\t\tstatus = photos.PhotoStatusHasDraft\n\t\t}\n\n\t\tresult[i] = PhotoWithStatus{\n\t\t\tReceiptPhoto: photo,\n\t\t\tStatus: status,\n\t\t\t// Удалить можно только если нет накладной\n\t\t\tCanDelete: photo.InvoiceID == nil,\n\t\t\t// Пересоздать можно только если это \"сирота\"\n\t\t\tCanRegenerate: photo.DraftID == nil && photo.InvoiceID == nil,\n\t\t}\n\t}\n\n\treturn result, total, nil\n}\n\nfunc (s *Service) DeletePhoto(userID, photoID uuid.UUID, forceDeleteDraft bool) error {\n\t// Проверка прав\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\treturn errors.New(\"no active server\")\n\t}\n\n\t// Операторы не могут удалять фото из архива (только админы)\n\trole, _ := s.accountRepo.GetUserRole(userID, server.ID)\n\tif role == account.RoleOperator {\n\t\treturn errors.New(\"access denied\")\n\t}\n\n\tphoto, err := s.photoRepo.GetByID(photoID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Проверка: есть накладная\n\tif photo.InvoiceID != nil {\n\t\treturn ErrPhotoHasInvoice\n\t}\n\n\t// Проверка: есть черновик\n\tif photo.DraftID != nil {\n\t\tif !forceDeleteDraft {\n\t\t\treturn ErrPhotoHasDraft\n\t\t}\n\t\t// Если форсируем удаление - удаляем и черновик\n\t\tif err := s.draftRepo.Delete(*photo.DraftID); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// Удаляем файл с диска (физически)\n\t// В продакшене лучше делать это асинхронно или не делать вовсе (soft delete),\n\t// но для экономии места удалим.\n\t// Путь в БД может быть относительным или абсолютным, зависит от реализации загрузки.\n\t// В ocr/service мы пишем абсолютный путь.\n\t// Но в поле FilePath у нас путь.\n\tif photo.FilePath != \"\" {\n\t\t_ = os.Remove(photo.FilePath)\n\t}\n\n\treturn s.photoRepo.Delete(photoID)\n}\n", - "internal/services/recommend/service.go": "package recommend\n\nimport (\n\t\"go.uber.org/zap\"\n\n\t\"rmser/internal/domain/recommendations\"\n\t\"rmser/pkg/logger\"\n)\n\nconst (\n\tAnalyzeDaysNoIncoming = 90 // Ищем ингредиенты без закупок за 30 дней\n\tAnalyzeDaysStale = 90 // Ищем неликвид за 60 дней\n)\n\ntype Service struct {\n\trepo recommendations.Repository\n}\n\nfunc NewService(repo recommendations.Repository) *Service {\n\treturn &Service{repo: repo}\n}\n\n// RefreshRecommendations выполняет анализ и сохраняет результаты в БД\nfunc (s *Service) RefreshRecommendations() error {\n\tlogger.Log.Info(\"Запуск пересчета рекомендаций...\")\n\n\tvar all []recommendations.Recommendation\n\n\t// 1. Unused\n\tif unused, err := s.repo.FindUnusedGoods(); err == nil {\n\t\tall = append(all, unused...)\n\t} else {\n\t\tlogger.Log.Error(\"Ошибка unused\", zap.Error(err))\n\t}\n\n\t// 2. Purchased but Unused\n\tif purchUnused, err := s.repo.FindPurchasedButUnused(AnalyzeDaysNoIncoming); err == nil {\n\t\tall = append(all, purchUnused...)\n\t} else {\n\t\tlogger.Log.Error(\"Ошибка purchased_unused\", zap.Error(err))\n\t}\n\n\t// 3. No Incoming (Ингредиенты без закупок)\n\tif noInc, err := s.repo.FindNoIncomingIngredients(AnalyzeDaysNoIncoming); err == nil {\n\t\tall = append(all, noInc...)\n\t} else {\n\t\tlogger.Log.Error(\"Ошибка no_incoming\", zap.Error(err))\n\t}\n\n\t// 4. Usage without Purchase (Расход без прихода) <-- НОВОЕ\n\tif usageNoPurch, err := s.repo.FindUsageWithoutPurchase(AnalyzeDaysNoIncoming); err == nil {\n\t\tall = append(all, usageNoPurch...)\n\t} else {\n\t\tlogger.Log.Error(\"Ошибка usage_no_purchase\", zap.Error(err))\n\t}\n\n\t// 5. Stale (Неликвид)\n\tif stale, err := s.repo.FindStaleGoods(AnalyzeDaysStale); err == nil {\n\t\tall = append(all, stale...)\n\t} else {\n\t\tlogger.Log.Error(\"Ошибка stale\", zap.Error(err))\n\t}\n\n\t// 6. Dish in Recipe\n\tif dishInRec, err := s.repo.FindDishesInRecipes(); err == nil {\n\t\tall = append(all, dishInRec...)\n\t} else {\n\t\tlogger.Log.Error(\"Ошибка dish_in_recipe\", zap.Error(err))\n\t}\n\n\t// Сохраняем\n\tif err := s.repo.SaveAll(all); err != nil {\n\t\treturn err\n\t}\n\n\tlogger.Log.Info(\"Рекомендации обновлены\", zap.Int(\"total_count\", len(all)))\n\treturn nil\n}\n\nfunc (s *Service) GetRecommendations() ([]recommendations.Recommendation, error) {\n\treturn s.repo.GetAll()\n}\n", - "internal/services/sync/service.go": "package sync\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/shopspring/decimal\"\n\t\"go.uber.org/zap\"\n\n\t\"rmser/internal/domain/account\"\n\t\"rmser/internal/domain/catalog\"\n\t\"rmser/internal/domain/invoices\"\n\t\"rmser/internal/domain/operations\"\n\t\"rmser/internal/domain/recipes\"\n\t\"rmser/internal/domain/suppliers\"\n\t\"rmser/internal/infrastructure/rms\"\n\t\"rmser/pkg/logger\"\n)\n\nconst (\n\tPresetPurchases = \"1a3297e1-cb05-55dc-98a7-c13f13bc85a7\" // Закупки\n\tPresetUsage = \"24d9402e-2d01-eca1-ebeb-7981f7d1cb86\" // Расход\n)\n\ntype Service struct {\n\trmsFactory *rms.Factory\n\taccountRepo account.Repository\n\tcatalogRepo catalog.Repository\n\trecipeRepo recipes.Repository\n\tinvoiceRepo invoices.Repository\n\topRepo operations.Repository\n\tsupplierRepo suppliers.Repository\n}\n\nfunc NewService(\n\trmsFactory *rms.Factory,\n\taccountRepo account.Repository,\n\tcatalogRepo catalog.Repository,\n\trecipeRepo recipes.Repository,\n\tinvoiceRepo invoices.Repository,\n\topRepo operations.Repository,\n\tsupplierRepo suppliers.Repository,\n) *Service {\n\treturn &Service{\n\t\trmsFactory: rmsFactory,\n\t\taccountRepo: accountRepo,\n\t\tcatalogRepo: catalogRepo,\n\t\trecipeRepo: recipeRepo,\n\t\tinvoiceRepo: invoiceRepo,\n\t\topRepo: opRepo,\n\t\tsupplierRepo: supplierRepo,\n\t}\n}\n\n// SyncAllData запускает полную синхронизацию для конкретного пользователя\nfunc (s *Service) SyncAllData(userID uuid.UUID, force bool) error {\n\tlogger.Log.Info(\"Запуск синхронизации\", zap.String(\"user_id\", userID.String()), zap.Bool(\"force\", force))\n\n\t// 1. Получаем клиент и инфо о сервере\n\tclient, err := s.rmsFactory.GetClientForUser(userID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\treturn fmt.Errorf(\"active server not found for user %s\", userID)\n\t}\n\tserverID := server.ID\n\n\t// 2. Справочники\n\tif err := s.syncStores(client, serverID); err != nil {\n\t\tlogger.Log.Error(\"Sync Stores failed\", zap.Error(err))\n\t}\n\tif err := s.syncMeasureUnits(client, serverID); err != nil {\n\t\tlogger.Log.Error(\"Sync Units failed\", zap.Error(err))\n\t}\n\n\t// 3. Поставщики\n\tif err := s.syncSuppliers(client, serverID); err != nil {\n\t\tlogger.Log.Error(\"Sync Suppliers failed\", zap.Error(err))\n\t}\n\n\t// 4. Товары\n\tif err := s.syncProducts(client, serverID); err != nil {\n\t\tlogger.Log.Error(\"Sync Products failed\", zap.Error(err))\n\t}\n\n\t// 5. Техкарты (тяжелый запрос)\n\tif err := s.syncRecipes(client, serverID); err != nil {\n\t\tlogger.Log.Error(\"Sync Recipes failed\", zap.Error(err))\n\t}\n\n\t// 6. Накладные (история)\n\tif err := s.syncInvoices(client, serverID, force); err != nil {\n\t\tlogger.Log.Error(\"Sync Invoices failed\", zap.Error(err))\n\t}\n\n\t// 7. Складские операции (тяжелый запрос)\n\t// Для MVP можно отключить, если долго грузится\n\t// if err := s.SyncStoreOperations(client, serverID); err != nil {\n\t// \tlogger.Log.Error(\"Sync Operations failed\", zap.Error(err))\n\t// }\n\n\tlogger.Log.Info(\"Синхронизация завершена\", zap.String(\"user_id\", userID.String()))\n\treturn nil\n}\n\n// SyncInvoicesOnly запускает синхронизацию только накладных для конкретного пользователя\nfunc (s *Service) SyncInvoicesOnly(userID uuid.UUID) error {\n\tlogger.Log.Info(\"Запуск синхронизации накладных\", zap.String(\"user_id\", userID.String()))\n\n\t// Получаем клиент и инфо о сервере\n\tclient, err := s.rmsFactory.GetClientForUser(userID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\treturn fmt.Errorf(\"active server not found for user %s\", userID)\n\t}\n\tserverID := server.ID\n\n\t// Синхронизация накладных\n\tif err := s.syncInvoices(client, serverID, false); err != nil {\n\t\tlogger.Log.Error(\"Sync Invoices failed\", zap.Error(err))\n\t\treturn err\n\t}\n\n\tlogger.Log.Info(\"Синхронизация накладных завершена\", zap.String(\"user_id\", userID.String()))\n\treturn nil\n}\n\nfunc (s *Service) syncSuppliers(c rms.ClientI, serverID uuid.UUID) error {\n\tlist, err := c.FetchSuppliers()\n\tif err != nil {\n\t\treturn err\n\t}\n\t// Проставляем ServerID\n\tfor i := range list {\n\t\tlist[i].RMSServerID = serverID\n\t}\n\treturn s.supplierRepo.SaveBatch(list)\n}\n\nfunc (s *Service) syncStores(c rms.ClientI, serverID uuid.UUID) error {\n\tstores, err := c.FetchStores()\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor i := range stores {\n\t\tstores[i].RMSServerID = serverID\n\t}\n\treturn s.catalogRepo.SaveStores(stores)\n}\n\nfunc (s *Service) syncMeasureUnits(c rms.ClientI, serverID uuid.UUID) error {\n\tunits, err := c.FetchMeasureUnits()\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor i := range units {\n\t\tunits[i].RMSServerID = serverID\n\t}\n\treturn s.catalogRepo.SaveMeasureUnits(units)\n}\n\nfunc (s *Service) syncProducts(c rms.ClientI, serverID uuid.UUID) error {\n\tproducts, err := c.FetchCatalog()\n\tif err != nil {\n\t\treturn err\n\t}\n\t// Важно: Проставляем ID рекурсивно и в фасовки\n\tfor i := range products {\n\t\tproducts[i].RMSServerID = serverID\n\t\tfor j := range products[i].Containers {\n\t\t\tproducts[i].Containers[j].RMSServerID = serverID\n\t\t}\n\t}\n\treturn s.catalogRepo.SaveProducts(products)\n}\n\nfunc (s *Service) syncRecipes(c rms.ClientI, serverID uuid.UUID) error {\n\tdateFrom := time.Now().AddDate(0, -3, 0) // За 3 месяца\n\tdateTo := time.Now()\n\trecipesList, err := c.FetchRecipes(dateFrom, dateTo)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor i := range recipesList {\n\t\trecipesList[i].RMSServerID = serverID\n\t\tfor j := range recipesList[i].Items {\n\t\t\trecipesList[i].Items[j].RMSServerID = serverID\n\t\t}\n\t}\n\treturn s.recipeRepo.SaveRecipes(recipesList)\n}\n\nfunc (s *Service) syncInvoices(c rms.ClientI, serverID uuid.UUID, force bool) error {\n\tvar from time.Time\n\tto := time.Now()\n\n\tif force {\n\t\t// Принудительная перезагрузка за последние 40 дней\n\t\tfrom = time.Now().AddDate(0, 0, -40)\n\t\tlogger.Log.Info(\"Force sync invoices\", zap.String(\"from\", from.Format(\"2006-01-02\")))\n\t} else {\n\t\tlastDate, err := s.invoiceRepo.GetLastInvoiceDate(serverID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif lastDate != nil {\n\t\t\tfrom = *lastDate\n\t\t} else {\n\t\t\tfrom = time.Now().AddDate(0, 0, -45)\n\t\t}\n\t}\n\n\tinvs, err := c.FetchInvoices(from, to)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor i := range invs {\n\t\tinvs[i].RMSServerID = serverID\n\t}\n\n\tif len(invs) > 0 {\n\t\t// Репозиторий использует OnConflict(UpdateAll), поэтому существующие записи обновятся\n\t\treturn s.invoiceRepo.SaveInvoices(invs)\n\t}\n\treturn nil\n}\n\n// SyncStoreOperations публичный, если нужно вызывать отдельно\nfunc (s *Service) SyncStoreOperations(c rms.ClientI, serverID uuid.UUID) error {\n\tdateTo := time.Now()\n\tdateFrom := dateTo.AddDate(0, 0, -30)\n\n\tif err := s.syncReport(c, serverID, PresetPurchases, operations.OpTypePurchase, dateFrom, dateTo); err != nil {\n\t\treturn fmt.Errorf(\"purchases sync error: %w\", err)\n\t}\n\tif err := s.syncReport(c, serverID, PresetUsage, operations.OpTypeUsage, dateFrom, dateTo); err != nil {\n\t\treturn fmt.Errorf(\"usage sync error: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc (s *Service) syncReport(c rms.ClientI, serverID uuid.UUID, presetID string, targetOpType operations.OperationType, from, to time.Time) error {\n\titems, err := c.FetchStoreOperations(presetID, from, to)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tvar ops []operations.StoreOperation\n\tfor _, item := range items {\n\t\tpID, err := uuid.Parse(item.ProductID)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\trealOpType := classifyOperation(item.DocumentType)\n\t\tif realOpType == operations.OpTypeUnknown || realOpType != targetOpType {\n\t\t\tcontinue\n\t\t}\n\n\t\tops = append(ops, operations.StoreOperation{\n\t\t\tRMSServerID: serverID,\n\t\t\tProductID: pID,\n\t\t\tOpType: realOpType,\n\t\t\tDocumentType: item.DocumentType,\n\t\t\tTransactionType: item.TransactionType,\n\t\t\tDocumentNumber: item.DocumentNum,\n\t\t\tAmount: decimal.NewFromFloat(item.Amount),\n\t\t\tSum: decimal.NewFromFloat(item.Sum),\n\t\t\tCost: decimal.NewFromFloat(item.Cost),\n\t\t\tPeriodFrom: from,\n\t\t\tPeriodTo: to,\n\t\t})\n\t}\n\n\treturn s.opRepo.SaveOperations(ops, serverID, targetOpType, from, to)\n}\n\nfunc classifyOperation(docType string) operations.OperationType {\n\tswitch docType {\n\tcase \"INCOMING_INVOICE\", \"INCOMING_SERVICE\":\n\t\treturn operations.OpTypePurchase\n\tcase \"SALES_DOCUMENT\", \"WRITEOFF_DOCUMENT\", \"OUTGOING_INVOICE\", \"SESSION_ACCEPTANCE\", \"DISASSEMBLE_DOCUMENT\":\n\t\treturn operations.OpTypeUsage\n\tdefault:\n\t\treturn operations.OpTypeUnknown\n\t}\n}\n\n// Добавляем структуру для возврата статистики\ntype SyncStats struct {\n\tServerName string\n\tProductsCount int64\n\tStoresCount int64\n\tSuppliersCount int64\n\tInvoicesLast30 int64\n\tLastInvoice *time.Time\n}\n\n// GetSyncStats собирает информацию о данных текущего сервера\nfunc (s *Service) GetSyncStats(userID uuid.UUID) (*SyncStats, error) {\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\treturn nil, fmt.Errorf(\"нет активного сервера\")\n\t}\n\n\tstats := &SyncStats{\n\t\tServerName: server.Name,\n\t}\n\n\t// Параллельный запуск не обязателен, запросы Count очень быстрые\n\tif cnt, err := s.catalogRepo.CountGoods(server.ID); err == nil {\n\t\tstats.ProductsCount = cnt\n\t}\n\n\tif cnt, err := s.catalogRepo.CountStores(server.ID); err == nil {\n\t\tstats.StoresCount = cnt\n\t}\n\n\tif cnt, err := s.supplierRepo.Count(server.ID); err == nil {\n\t\tstats.SuppliersCount = cnt\n\t}\n\n\tif cnt, err := s.invoiceRepo.CountRecent(server.ID, 30); err == nil {\n\t\tstats.InvoicesLast30 = cnt\n\t}\n\n\tstats.LastInvoice, _ = s.invoiceRepo.GetLastInvoiceDate(server.ID)\n\n\treturn stats, nil\n}\n", - "internal/transport/http/handlers/billing.go": "package handlers\n\nimport (\n\t\"net/http\"\n\t\"rmser/internal/infrastructure/yookassa\"\n\t\"rmser/internal/services/billing\"\n\t\"rmser/pkg/logger\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"go.uber.org/zap\"\n)\n\ntype BillingHandler struct {\n\tservice *billing.Service\n}\n\nfunc NewBillingHandler(s *billing.Service) *BillingHandler {\n\treturn &BillingHandler{service: s}\n}\n\nfunc (h *BillingHandler) YooKassaWebhook(c *gin.Context) {\n\tvar event yookassa.WebhookEvent\n\tif err := c.ShouldBindJSON(&event); err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid json\"})\n\t\treturn\n\t}\n\n\tlogger.Log.Info(\"YooKassa Webhook received\",\n\t\tzap.String(\"event\", event.Event),\n\t\tzap.String(\"payment_id\", event.Object.ID),\n\t)\n\n\tif err := h.service.ProcessWebhook(c.Request.Context(), event); err != nil {\n\t\tlogger.Log.Error(\"Failed to process webhook\", zap.Error(err))\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"status\": \"error\"})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\"status\": \"ok\"})\n}\n", - "internal/transport/http/handlers/drafts.go": "package handlers\n\nimport (\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/google/uuid\"\n\t\"github.com/shopspring/decimal\"\n\t\"go.uber.org/zap\"\n\n\t\"rmser/internal/services/drafts\"\n\t\"rmser/pkg/logger\"\n)\n\ntype DraftsHandler struct {\n\tservice *drafts.Service\n}\n\nfunc NewDraftsHandler(service *drafts.Service) *DraftsHandler {\n\treturn &DraftsHandler{service: service}\n}\n\nfunc (h *DraftsHandler) GetDraft(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\tidStr := c.Param(\"id\")\n\tid, err := uuid.Parse(idStr)\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid id\"})\n\t\treturn\n\t}\n\n\tdraft, err := h.service.GetDraft(id, userID)\n\tif err != nil {\n\t\tc.JSON(http.StatusNotFound, gin.H{\"error\": \"draft not found\"})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, draft)\n}\n\nfunc (h *DraftsHandler) GetDictionaries(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\n\tdata, err := h.service.GetDictionaries(userID)\n\tif err != nil {\n\t\tlogger.Log.Error(\"GetDictionaries error\", zap.Error(err))\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, data)\n}\n\nfunc (h *DraftsHandler) GetStores(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\n\tdict, err := h.service.GetDictionaries(userID)\n\tif err != nil {\n\t\tlogger.Log.Error(\"GetStores error\", zap.Error(err))\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, dict[\"stores\"])\n}\n\n// UpdateItemDTO обновлен: float64 -> *float64, добавлен edited_field\ntype UpdateItemDTO struct {\n\tProductID *string `json:\"product_id\"`\n\tContainerID *string `json:\"container_id\"`\n\tQuantity *float64 `json:\"quantity\"`\n\tPrice *float64 `json:\"price\"`\n\tSum *float64 `json:\"sum\"`\n\tEditedField string `json:\"edited_field\"` // \"quantity\", \"price\", \"sum\"\n}\n\nfunc (h *DraftsHandler) AddDraftItem(c *gin.Context) {\n\tdraftID, err := uuid.Parse(c.Param(\"id\"))\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid draft id\"})\n\t\treturn\n\t}\n\n\titem, err := h.service.AddItem(draftID)\n\tif err != nil {\n\t\tlogger.Log.Error(\"Failed to add item\", zap.Error(err))\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, item)\n}\n\nfunc (h *DraftsHandler) DeleteDraftItem(c *gin.Context) {\n\tdraftID, err := uuid.Parse(c.Param(\"id\"))\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid draft id\"})\n\t\treturn\n\t}\n\titemID, err := uuid.Parse(c.Param(\"itemId\"))\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid item id\"})\n\t\treturn\n\t}\n\n\tnewTotal, err := h.service.DeleteItem(draftID, itemID)\n\tif err != nil {\n\t\tlogger.Log.Error(\"Failed to delete item\", zap.Error(err))\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"status\": \"deleted\",\n\t\t\"id\": itemID.String(),\n\t\t\"total_sum\": newTotal,\n\t})\n}\n\n// UpdateItem обновлен\nfunc (h *DraftsHandler) UpdateItem(c *gin.Context) {\n\tdraftID, _ := uuid.Parse(c.Param(\"id\"))\n\titemID, _ := uuid.Parse(c.Param(\"itemId\"))\n\n\tvar req UpdateItemDTO\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tvar pID *uuid.UUID\n\tif req.ProductID != nil && *req.ProductID != \"\" {\n\t\tif uid, err := uuid.Parse(*req.ProductID); err == nil {\n\t\t\tpID = &uid\n\t\t}\n\t}\n\n\tvar cID *uuid.UUID\n\tif req.ContainerID != nil {\n\t\tif *req.ContainerID == \"\" {\n\t\t\t// Сброс фасовки\n\t\t\tempty := uuid.Nil\n\t\t\tcID = &empty\n\t\t} else if uid, err := uuid.Parse(*req.ContainerID); err == nil {\n\t\t\tcID = &uid\n\t\t}\n\t}\n\n\tqty := decimal.Zero\n\tif req.Quantity != nil {\n\t\tqty = decimal.NewFromFloat(*req.Quantity)\n\t}\n\n\tprice := decimal.Zero\n\tif req.Price != nil {\n\t\tprice = decimal.NewFromFloat(*req.Price)\n\t}\n\n\tsum := decimal.Zero\n\tif req.Sum != nil {\n\t\tsum = decimal.NewFromFloat(*req.Sum)\n\t}\n\n\t// Дефолт, если фронт не прислал (для совместимости)\n\teditedField := req.EditedField\n\tif editedField == \"\" {\n\t\tif req.Sum != nil {\n\t\t\teditedField = \"sum\"\n\t\t} else if req.Price != nil {\n\t\t\teditedField = \"price\"\n\t\t} else {\n\t\t\teditedField = \"quantity\"\n\t\t}\n\t}\n\n\tif err := h.service.UpdateItem(draftID, itemID, pID, cID, qty, price, sum, editedField); err != nil {\n\t\tlogger.Log.Error(\"Failed to update item\", zap.Error(err))\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\"status\": \"updated\"})\n}\n\ntype CommitRequestDTO struct {\n\tDateIncoming string `json:\"date_incoming\"` // YYYY-MM-DD\n\tStoreID string `json:\"store_id\"`\n\tSupplierID string `json:\"supplier_id\"`\n\tComment string `json:\"comment\"`\n\tIncomingDocNum string `json:\"incoming_doc_num\"`\n}\n\nfunc (h *DraftsHandler) CommitDraft(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\tdraftID, err := uuid.Parse(c.Param(\"id\"))\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid draft id\"})\n\t\treturn\n\t}\n\n\tvar req CommitRequestDTO\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tdate, err := time.Parse(\"2006-01-02\", req.DateIncoming)\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid date format\"})\n\t\treturn\n\t}\n\tstoreID, err := uuid.Parse(req.StoreID)\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid store id\"})\n\t\treturn\n\t}\n\tsupplierID, err := uuid.Parse(req.SupplierID)\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid supplier id\"})\n\t\treturn\n\t}\n\n\tif err := h.service.UpdateDraftHeader(draftID, &storeID, &supplierID, date, req.Comment, req.IncomingDocNum); err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"failed to update header: \" + err.Error()})\n\t\treturn\n\t}\n\n\tdocNum, err := h.service.CommitDraft(draftID, userID)\n\tif err != nil {\n\t\tlogger.Log.Error(\"Commit failed\", zap.Error(err))\n\t\tc.JSON(http.StatusBadGateway, gin.H{\"error\": \"RMS error: \" + err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\"status\": \"completed\", \"document_number\": docNum})\n}\n\ntype AddContainerRequestDTO struct {\n\tProductID string `json:\"product_id\" binding:\"required\"`\n\tName string `json:\"name\" binding:\"required\"`\n\tCount float64 `json:\"count\" binding:\"required,gt=0\"`\n}\n\nfunc (h *DraftsHandler) AddContainer(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\n\tvar req AddContainerRequestDTO\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tpID, err := uuid.Parse(req.ProductID)\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid product_id\"})\n\t\treturn\n\t}\n\n\tcountDec := decimal.NewFromFloat(req.Count)\n\n\tnewID, err := h.service.CreateProductContainer(userID, pID, req.Name, countDec)\n\tif err != nil {\n\t\tlogger.Log.Error(\"Failed to create container\", zap.Error(err))\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\"status\": \"created\", \"container_id\": newID.String()})\n}\n\nfunc (h *DraftsHandler) GetDrafts(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\n\tfromStr := c.DefaultQuery(\"from\", time.Now().AddDate(0, 0, -30).Format(\"2006-01-02\"))\n\ttoStr := c.DefaultQuery(\"to\", time.Now().Format(\"2006-01-02\"))\n\n\tfrom, _ := time.Parse(\"2006-01-02\", fromStr)\n\tto, _ := time.Parse(\"2006-01-02\", toStr)\n\tto = to.Add(23*time.Hour + 59*time.Minute + 59*time.Second)\n\n\tlist, err := h.service.GetUnifiedList(userID, from, to)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, list)\n}\n\nfunc (h *DraftsHandler) DeleteDraft(c *gin.Context) {\n\tidStr := c.Param(\"id\")\n\tid, err := uuid.Parse(idStr)\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid id\"})\n\t\treturn\n\t}\n\n\tnewStatus, err := h.service.DeleteDraft(id)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\"status\": newStatus, \"id\": id.String()})\n}\n", - "internal/transport/http/handlers/invoices.go": "package handlers\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/google/uuid\"\n\t\"go.uber.org/zap\"\n\n\t\"rmser/internal/services/drafts\"\n\tinvService \"rmser/internal/services/invoices\"\n\t\"rmser/internal/services/sync\"\n\t\"rmser/pkg/logger\"\n)\n\ntype InvoiceHandler struct {\n\tservice *invService.Service\n\tdraftsService *drafts.Service\n\tsyncService *sync.Service\n}\n\nfunc NewInvoiceHandler(service *invService.Service, syncService *sync.Service) *InvoiceHandler {\n\treturn &InvoiceHandler{service: service, syncService: syncService}\n}\n\n// SendInvoice godoc\n// @Summary Создать приходную накладную в iikoRMS\n// @Description Принимает JSON с данными накладной и отправляет их в iiko\n// @Tags invoices\n// @Accept json\n// @Produce json\n// @Param input body invService.CreateRequestDTO true \"Invoice Data\"\n// @Success 200 {object} map[string]string \"created_number\"\n// @Failure 400 {object} map[string]string\n// @Failure 500 {object} map[string]string\nfunc (h *InvoiceHandler) SendInvoice(c *gin.Context) {\n\tvar req invService.CreateRequestDTO\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"Неверный формат JSON: \" + err.Error()})\n\t\treturn\n\t}\n\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\tdocNum, err := h.service.SendInvoiceToRMS(req, userID)\n\tif err != nil {\n\t\tlogger.Log.Error(\"Ошибка отправки накладной\", zap.Error(err))\n\t\t// Возвращаем 502 Bad Gateway, т.к. ошибка скорее всего на стороне RMS\n\t\tc.JSON(http.StatusBadGateway, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"status\": \"ok\",\n\t\t\"created_number\": docNum,\n\t})\n}\n\n// GetInvoice godoc\n// @Summary Получить детали накладной\n// @Description Возвращает детали синхронизированной накладной по ID\n// @Tags invoices\n// @Produce json\n// @Param id path string true \"Invoice ID\"\n// @Success 200 {object} invService.InvoiceDetailsDTO\n// @Failure 400 {object} map[string]string\n// @Failure 404 {object} map[string]string\n// @Failure 500 {object} map[string]string\nfunc (h *InvoiceHandler) GetInvoice(c *gin.Context) {\n\tid := c.Param(\"id\")\n\tif id == \"\" {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"ID накладной обязателен\"})\n\t\treturn\n\t}\n\n\tuuidID, err := uuid.Parse(id)\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"Неверный формат ID\"})\n\t\treturn\n\t}\n\n\tdto, err := h.service.GetInvoice(uuidID)\n\tif err != nil {\n\t\tlogger.Log.Error(\"Ошибка получения накладной\", zap.Error(err), zap.String(\"id\", id))\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"Ошибка получения накладной\"})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, dto)\n}\n\n// SyncInvoices godoc\n// @Summary Запустить синхронизацию накладных\n// @Description Запускает синхронизацию накладных для пользователя\n// @Tags invoices\n// @Produce json\n// @Success 200 {object} map[string]string\n// @Failure 500 {object} map[string]string\nfunc (h *InvoiceHandler) SyncInvoices(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\n\terr := h.syncService.SyncInvoicesOnly(userID)\n\tif err != nil {\n\t\tlogger.Log.Error(\"Ошибка синхронизации накладных\", zap.Error(err))\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"Ошибка синхронизации\"})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"status\": \"ok\",\n\t\t\"message\": \"Синхронизация запущена\",\n\t})\n}\n", - "internal/transport/http/handlers/ocr.go": "package handlers\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/google/uuid\"\n\t\"github.com/shopspring/decimal\"\n\t\"go.uber.org/zap\"\n\n\tocrService \"rmser/internal/services/ocr\"\n\t\"rmser/pkg/logger\"\n)\n\ntype OCRHandler struct {\n\tservice *ocrService.Service\n}\n\nfunc NewOCRHandler(service *ocrService.Service) *OCRHandler {\n\treturn &OCRHandler{service: service}\n}\n\n// GetCatalog возвращает список товаров для OCR сервиса\nfunc (h *OCRHandler) GetCatalog(c *gin.Context) {\n\t// Если этот эндпоинт дергает Python-скрипт без токена пользователя - это проблема безопасности.\n\t// Либо Python скрипт должен передавать токен админа/системы и ID сервера в query.\n\t// ПОКА: Предполагаем, что запрос идет от фронта или с заголовком X-Telegram-User-ID.\n\n\t// Если заголовка нет (вызов от скрипта), пробуем взять server_id из query (небезопасно, но для MVP)\n\t// Или лучше так: этот метод вызывается Фронтендом для поиска? Нет, название GetCatalogForIndexing намекает на OCR.\n\t// Оставим пока требование UserID.\n\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\n\titems, err := h.service.GetCatalogForIndexing(userID)\n\tif err != nil {\n\t\tlogger.Log.Error(\"Ошибка получения каталога\", zap.Error(err))\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, items)\n}\n\ntype MatchRequest struct {\n\tRawName string `json:\"raw_name\" binding:\"required\"`\n\tProductID string `json:\"product_id\" binding:\"required\"`\n\tQuantity float64 `json:\"quantity\"`\n\tContainerID *string `json:\"container_id\"`\n}\n\nfunc (h *OCRHandler) SaveMatch(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\n\tvar req MatchRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tpID, err := uuid.Parse(req.ProductID)\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid product_id format\"})\n\t\treturn\n\t}\n\n\tqty := decimal.NewFromFloat(1.0)\n\tif req.Quantity > 0 {\n\t\tqty = decimal.NewFromFloat(req.Quantity)\n\t}\n\n\tvar contID *uuid.UUID\n\tif req.ContainerID != nil && *req.ContainerID != \"\" {\n\t\tif uid, err := uuid.Parse(*req.ContainerID); err == nil {\n\t\t\tcontID = &uid\n\t\t}\n\t}\n\n\tif err := h.service.SaveMapping(userID, req.RawName, pID, qty, contID); err != nil {\n\t\tlogger.Log.Error(\"Ошибка сохранения матчинга\", zap.Error(err))\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\"status\": \"saved\"})\n}\n\nfunc (h *OCRHandler) DeleteMatch(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\trawName := c.Query(\"raw_name\")\n\n\tif rawName == \"\" {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"raw_name is required\"})\n\t\treturn\n\t}\n\n\tif err := h.service.DeleteMatch(userID, rawName); err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\"status\": \"deleted\"})\n}\n\nfunc (h *OCRHandler) SearchProducts(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\tquery := c.Query(\"q\")\n\n\tproducts, err := h.service.SearchProducts(userID, query)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, products)\n}\n\nfunc (h *OCRHandler) GetMatches(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\tmatches, err := h.service.GetKnownMatches(userID)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, matches)\n}\n\nfunc (h *OCRHandler) GetUnmatched(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\titems, err := h.service.GetUnmatchedItems(userID)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, items)\n}\n\nfunc (h *OCRHandler) DiscardUnmatched(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\trawName := c.Query(\"raw_name\")\n\n\tif rawName == \"\" {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"raw_name is required\"})\n\t\treturn\n\t}\n\n\tif err := h.service.DiscardUnmatched(userID, rawName); err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\"status\": \"discarded\"})\n}\n", - "internal/transport/http/handlers/photos.go": "package handlers\n\nimport (\n\t\"net/http\"\n\t\"strconv\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/google/uuid\"\n\n\tphotosSvc \"rmser/internal/services/photos\"\n)\n\ntype PhotosHandler struct {\n\tservice *photosSvc.Service\n}\n\nfunc NewPhotosHandler(service *photosSvc.Service) *PhotosHandler {\n\treturn &PhotosHandler{service: service}\n}\n\n// GetPhotos GET /api/photos\nfunc (h *PhotosHandler) GetPhotos(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\n\tpage, _ := strconv.Atoi(c.DefaultQuery(\"page\", \"1\"))\n\tlimit, _ := strconv.Atoi(c.DefaultQuery(\"limit\", \"20\"))\n\n\tphotos, total, err := h.service.GetPhotosForServer(userID, page, limit)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"photos\": photos,\n\t\t\"total\": total,\n\t\t\"page\": page,\n\t\t\"limit\": limit,\n\t})\n}\n\n// DeletePhoto DELETE /api/photos/:id\nfunc (h *PhotosHandler) DeletePhoto(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\tphotoID, err := uuid.Parse(c.Param(\"id\"))\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid id\"})\n\t\treturn\n\t}\n\n\tforceDeleteDraft := c.Query(\"force\") == \"true\"\n\n\terr = h.service.DeletePhoto(userID, photoID, forceDeleteDraft)\n\tif err != nil {\n\t\tif err == photosSvc.ErrPhotoHasDraft {\n\t\t\t// Специальный статус, чтобы фронт показал Confirm\n\t\t\tc.JSON(http.StatusConflict, gin.H{\n\t\t\t\t\"error\": err.Error(),\n\t\t\t\t\"requires_confirm\": true,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\"status\": \"deleted\"})\n}\n", - "internal/transport/http/handlers/recommendations.go": "package handlers\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"go.uber.org/zap\"\n\n\t\"rmser/internal/services/recommend\"\n\t\"rmser/pkg/logger\"\n)\n\ntype RecommendationsHandler struct {\n\tservice *recommend.Service\n}\n\nfunc NewRecommendationsHandler(service *recommend.Service) *RecommendationsHandler {\n\treturn &RecommendationsHandler{service: service}\n}\n\n// GetRecommendations godoc\n// @Summary Получить список рекомендаций\n// @Description Возвращает сгенерированные рекомендации (проблемные зоны учета)\n// @Tags recommendations\n// @Produce json\n// @Success 200 {array} recommendations.Recommendation\n// @Failure 500 {object} map[string]string\nfunc (h *RecommendationsHandler) GetRecommendations(c *gin.Context) {\n\trecs, err := h.service.GetRecommendations()\n\tif err != nil {\n\t\tlogger.Log.Error(\"Ошибка получения рекомендаций\", zap.Error(err))\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, recs)\n}\n", - "internal/transport/http/handlers/settings.go": "package handlers\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/google/uuid\"\n\t\"go.uber.org/zap\"\n\n\t\"rmser/internal/domain/account\"\n\t\"rmser/internal/domain/catalog\"\n\t\"rmser/pkg/logger\"\n)\n\n// Notifier - интерфейс для отправки уведомлений (реализуется ботом)\ntype Notifier interface {\n\tSendRoleChangeNotification(telegramID int64, serverName string, newRole string)\n\tSendRemovalNotification(telegramID int64, serverName string)\n}\n\ntype SettingsHandler struct {\n\taccountRepo account.Repository\n\tcatalogRepo catalog.Repository\n\tnotifier Notifier // Поле для отправки уведомлений\n}\n\nfunc NewSettingsHandler(accRepo account.Repository, catRepo catalog.Repository) *SettingsHandler {\n\treturn &SettingsHandler{\n\t\taccountRepo: accRepo,\n\t\tcatalogRepo: catRepo,\n\t}\n}\n\n// SetNotifier используется для внедрения зависимости после инициализации\nfunc (h *SettingsHandler) SetNotifier(n Notifier) {\n\th.notifier = n\n}\n\n// SettingsResponse - DTO для отдачи настроек\ntype SettingsResponse struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tBaseURL string `json:\"base_url\"`\n\tDefaultStoreID *string `json:\"default_store_id\"` // Nullable\n\tRootGroupID *string `json:\"root_group_id\"` // Nullable\n\tAutoConduct bool `json:\"auto_conduct\"`\n\tRole string `json:\"role\"` // OWNER, ADMIN, OPERATOR\n}\n\n// GetSettings возвращает настройки активного сервера + роль пользователя\nfunc (h *SettingsHandler) GetSettings(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\n\tserver, err := h.accountRepo.GetActiveServer(userID)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\tif server == nil {\n\t\tc.JSON(http.StatusNotFound, gin.H{\"error\": \"No active server\"})\n\t\treturn\n\t}\n\n\trole, err := h.accountRepo.GetUserRole(userID, server.ID)\n\tif err != nil {\n\t\trole = account.RoleOperator\n\t}\n\n\tresp := SettingsResponse{\n\t\tID: server.ID.String(),\n\t\tName: server.Name,\n\t\tBaseURL: server.BaseURL,\n\t\tAutoConduct: server.AutoProcess,\n\t\tRole: string(role),\n\t}\n\n\tif server.DefaultStoreID != nil {\n\t\ts := server.DefaultStoreID.String()\n\t\tresp.DefaultStoreID = &s\n\t}\n\tif server.RootGroupGUID != nil {\n\t\ts := server.RootGroupGUID.String()\n\t\tresp.RootGroupID = &s\n\t}\n\n\tc.JSON(http.StatusOK, resp)\n}\n\n// UpdateSettingsDTO\ntype UpdateSettingsDTO struct {\n\tName string `json:\"name\"`\n\tDefaultStoreID string `json:\"default_store_id\"`\n\tRootGroupID string `json:\"root_group_id\"`\n\tAutoProcess bool `json:\"auto_process\"`\n\tAutoConduct bool `json:\"auto_conduct\"`\n}\n\n// UpdateSettings сохраняет настройки\nfunc (h *SettingsHandler) UpdateSettings(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\n\tvar req UpdateSettingsDTO\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tserver, err := h.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\tc.JSON(http.StatusNotFound, gin.H{\"error\": \"No active server\"})\n\t\treturn\n\t}\n\n\t// ПРОВЕРКА ПРАВ\n\trole, err := h.accountRepo.GetUserRole(userID, server.ID)\n\tif err != nil {\n\t\tc.JSON(http.StatusForbidden, gin.H{\"error\": \"Access check failed\"})\n\t\treturn\n\t}\n\tif role != account.RoleOwner && role != account.RoleAdmin {\n\t\tc.JSON(http.StatusForbidden, gin.H{\"error\": \"У вас нет прав на изменение настроек сервера (требуется ADMIN или OWNER)\"})\n\t\treturn\n\t}\n\n\tif req.Name != \"\" {\n\t\tserver.Name = req.Name\n\t}\n\n\tif req.AutoConduct {\n\t\tserver.AutoProcess = true\n\t} else {\n\t\tserver.AutoProcess = req.AutoProcess || req.AutoConduct\n\t}\n\n\tif req.DefaultStoreID != \"\" {\n\t\tif uid, err := uuid.Parse(req.DefaultStoreID); err == nil {\n\t\t\tserver.DefaultStoreID = &uid\n\t\t}\n\t} else {\n\t\tserver.DefaultStoreID = nil\n\t}\n\n\tif req.RootGroupID != \"\" {\n\t\tif uid, err := uuid.Parse(req.RootGroupID); err == nil {\n\t\t\tserver.RootGroupGUID = &uid\n\t\t}\n\t} else {\n\t\tserver.RootGroupGUID = nil\n\t}\n\n\tif err := h.accountRepo.SaveServerSettings(server); err != nil {\n\t\tlogger.Log.Error(\"Failed to save settings\", zap.Error(err))\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\th.GetSettings(c)\n}\n\n// --- Group Tree Logic ---\n\ntype GroupNode struct {\n\tKey string `json:\"key\"`\n\tValue string `json:\"value\"`\n\tTitle string `json:\"title\"`\n\tChildren []*GroupNode `json:\"children\"`\n}\n\nfunc (h *SettingsHandler) GetGroupsTree(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\tserver, err := h.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\tc.JSON(http.StatusNotFound, gin.H{\"error\": \"No active server\"})\n\t\treturn\n\t}\n\n\tgroups, err := h.catalogRepo.GetGroups(server.ID)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\ttree := buildTree(groups)\n\tc.JSON(http.StatusOK, tree)\n}\n\nfunc buildTree(flat []catalog.Product) []*GroupNode {\n\tnodeMap := make(map[uuid.UUID]*GroupNode)\n\tfor _, g := range flat {\n\t\tnodeMap[g.ID] = &GroupNode{\n\t\t\tKey: g.ID.String(),\n\t\t\tValue: g.ID.String(),\n\t\t\tTitle: g.Name,\n\t\t\tChildren: make([]*GroupNode, 0),\n\t\t}\n\t}\n\n\tvar roots []*GroupNode\n\tfor _, g := range flat {\n\t\tnode := nodeMap[g.ID]\n\t\tif g.ParentID != nil {\n\t\t\tif parent, exists := nodeMap[*g.ParentID]; exists {\n\t\t\t\tparent.Children = append(parent.Children, node)\n\t\t\t} else {\n\t\t\t\troots = append(roots, node)\n\t\t\t}\n\t\t} else {\n\t\t\troots = append(roots, node)\n\t\t}\n\t}\n\treturn roots\n}\n\n// --- User Management ---\n\nfunc (h *SettingsHandler) GetServerUsers(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\n\tserver, err := h.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\tc.JSON(http.StatusNotFound, gin.H{\"error\": \"No active server\"})\n\t\treturn\n\t}\n\n\tmyRole, err := h.accountRepo.GetUserRole(userID, server.ID)\n\tif err != nil || (myRole != account.RoleOwner && myRole != account.RoleAdmin) {\n\t\tc.JSON(http.StatusForbidden, gin.H{\"error\": \"Access denied\"})\n\t\treturn\n\t}\n\n\tusers, err := h.accountRepo.GetServerUsers(server.ID)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\ttype UserDTO struct {\n\t\tUserID uuid.UUID `json:\"user_id\"`\n\t\tUsername string `json:\"username\"`\n\t\tFirstName string `json:\"first_name\"`\n\t\tLastName string `json:\"last_name\"`\n\t\tPhotoURL string `json:\"photo_url\"`\n\t\tRole account.Role `json:\"role\"`\n\t\tIsMe bool `json:\"is_me\"`\n\t}\n\n\tresponse := make([]UserDTO, 0, len(users))\n\tfor _, u := range users {\n\t\tresponse = append(response, UserDTO{\n\t\t\tUserID: u.UserID,\n\t\t\tUsername: u.User.Username,\n\t\t\tFirstName: u.User.FirstName,\n\t\t\tLastName: u.User.LastName,\n\t\t\tPhotoURL: u.User.PhotoURL,\n\t\t\tRole: u.Role,\n\t\t\tIsMe: u.UserID == userID,\n\t\t})\n\t}\n\n\tc.JSON(http.StatusOK, response)\n}\n\ntype UpdateUserRoleDTO struct {\n\tNewRole string `json:\"new_role\" binding:\"required\"` // ADMIN, OPERATOR\n}\n\nfunc (h *SettingsHandler) UpdateUserRole(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\ttargetUserID, err := uuid.Parse(c.Param(\"userId\"))\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid target user id\"})\n\t\treturn\n\t}\n\n\tvar req UpdateUserRoleDTO\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tnewRole := account.Role(req.NewRole)\n\tif newRole != account.RoleAdmin && newRole != account.RoleOperator {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid role (allowed: ADMIN, OPERATOR)\"})\n\t\treturn\n\t}\n\n\tserver, err := h.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\tc.JSON(http.StatusNotFound, gin.H{\"error\": \"No active server\"})\n\t\treturn\n\t}\n\n\tmyRole, _ := h.accountRepo.GetUserRole(userID, server.ID)\n\tif myRole != account.RoleOwner {\n\t\tc.JSON(http.StatusForbidden, gin.H{\"error\": \"Only OWNER can change roles\"})\n\t\treturn\n\t}\n\n\tif userID == targetUserID {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"Cannot change own role\"})\n\t\treturn\n\t}\n\n\tif err := h.accountRepo.SetUserRole(server.ID, targetUserID, newRole); err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\t// --- УВЕДОМЛЕНИЕ О СМЕНЕ РОЛИ ---\n\tif h.notifier != nil {\n\t\tgo func() {\n\t\t\tusers, err := h.accountRepo.GetServerUsers(server.ID)\n\t\t\tif err == nil {\n\t\t\t\tfor _, u := range users {\n\t\t\t\t\tif u.UserID == targetUserID {\n\t\t\t\t\t\th.notifier.SendRoleChangeNotification(u.User.TelegramID, server.Name, string(newRole))\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}()\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\"status\": \"updated\"})\n}\n\nfunc (h *SettingsHandler) RemoveUser(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\ttargetUserID, err := uuid.Parse(c.Param(\"userId\"))\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid target user id\"})\n\t\treturn\n\t}\n\n\tserver, err := h.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\tc.JSON(http.StatusNotFound, gin.H{\"error\": \"No active server\"})\n\t\treturn\n\t}\n\n\tmyRole, _ := h.accountRepo.GetUserRole(userID, server.ID)\n\tif myRole != account.RoleOwner && myRole != account.RoleAdmin {\n\t\tc.JSON(http.StatusForbidden, gin.H{\"error\": \"Access denied\"})\n\t\treturn\n\t}\n\n\tif userID == targetUserID {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"Use 'leave' function instead\"})\n\t\treturn\n\t}\n\n\t// Ищем цель в списке, чтобы проверить права и получить TelegramID для уведомления\n\tusers, _ := h.accountRepo.GetServerUsers(server.ID)\n\tvar targetTgID int64\n\tvar found bool\n\n\tfor _, u := range users {\n\t\tif u.UserID == targetUserID {\n\t\t\tfound = true\n\t\t\ttargetTgID = u.User.TelegramID\n\n\t\t\tif u.Role == account.RoleOwner {\n\t\t\t\tc.JSON(http.StatusForbidden, gin.H{\"error\": \"Cannot remove Owner\"})\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif myRole == account.RoleAdmin && u.Role == account.RoleAdmin {\n\t\t\t\tc.JSON(http.StatusForbidden, gin.H{\"error\": \"Admins cannot remove other Admins\"})\n\t\t\t\treturn\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif !found {\n\t\tc.JSON(http.StatusNotFound, gin.H{\"error\": \"Target user not found on server\"})\n\t\treturn\n\t}\n\n\tif err := h.accountRepo.RemoveUserFromServer(server.ID, targetUserID); err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\t// --- УВЕДОМЛЕНИЕ ОБ УДАЛЕНИИ ---\n\tif h.notifier != nil && targetTgID != 0 {\n\t\tgo h.notifier.SendRemovalNotification(targetTgID, server.Name)\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\"status\": \"removed\"})\n}\n", - "internal/transport/http/middleware/auth.go": "package middleware\n\nimport (\n\t\"crypto/hmac\"\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"rmser/internal/domain/account\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\n// AuthMiddleware проверяет initData от Telegram\nfunc AuthMiddleware(accountRepo account.Repository, botToken string, maintenanceMode bool, devIDs []int64) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\t// 1. Извлекаем данные авторизации\n\t\tauthHeader := c.GetHeader(\"Authorization\")\n\t\tvar initData string\n\n\t\tif strings.HasPrefix(authHeader, \"Bearer \") {\n\t\t\tinitData = strings.TrimPrefix(authHeader, \"Bearer \")\n\t\t} else {\n\t\t\t// Оставляем лазейку для отладки ТОЛЬКО если это не production режим\n\t\t\t// В реальности лучше всегда требовать подпись\n\t\t\tinitData = c.Query(\"_auth\")\n\t\t}\n\n\t\tif initData == \"\" {\n\t\t\tc.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{\"error\": \"Авторизация отклонена: отсутствует подпись Telegram\"})\n\t\t\treturn\n\t\t}\n\n\t\t// 2. Проверяем подпись (HMAC-SHA256)\n\t\tisValid, err := verifyTelegramInitData(initData, botToken)\n\t\tif !isValid || err != nil {\n\t\t\tc.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{\"error\": \"Критическая ошибка безопасности: поддельная подпись\"})\n\t\t\treturn\n\t\t}\n\n\t\t// 3. Извлекаем User ID из проверенных данных\n\t\tvalues, _ := url.ParseQuery(initData)\n\t\tuserJSON := values.Get(\"user\")\n\n\t\t// Извлекаем id вручную из JSON-подобной строки или через простой парсинг\n\t\t// Telegram передает user как JSON-объект: {\"id\":12345,\"first_name\":\"...\"}\n\t\ttgID, err := extractIDFromUserJSON(userJSON)\n\t\tif err != nil {\n\t\t\tc.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{\"error\": \"Не удалось извлечь Telegram ID\"})\n\t\t\treturn\n\t\t}\n\n\t\t// Проверка режима обслуживания: если включен, разрешаем доступ только разработчикам\n\t\tif maintenanceMode {\n\t\t\tisDev := false\n\t\t\tfor _, devID := range devIDs {\n\t\t\t\tif tgID == devID {\n\t\t\t\t\tisDev = true\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif !isDev {\n\t\t\t\tc.AbortWithStatusJSON(503, gin.H{\"error\": \"maintenance_mode\", \"message\": \"Сервис на обслуживании\"})\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\t// 4. Ищем пользователя в БД\n\t\tuser, err := accountRepo.GetUserByTelegramID(tgID)\n\t\tif err != nil {\n\t\t\tc.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{\"error\": \"Пользователь не зарегистрирован. Начните диалог с ботом.\"})\n\t\t\treturn\n\t\t}\n\n\t\tc.Set(\"userID\", user.ID)\n\t\tc.Set(\"telegramID\", tgID)\n\t\tc.Next()\n\t}\n}\n\n// verifyTelegramInitData реализует алгоритм проверки Telegram\nfunc verifyTelegramInitData(initData, token string) (bool, error) {\n\tvalues, err := url.ParseQuery(initData)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\thash := values.Get(\"hash\")\n\tif hash == \"\" {\n\t\treturn false, fmt.Errorf(\"no hash found\")\n\t}\n\tvalues.Del(\"hash\")\n\n\t// Сортируем ключи\n\tkeys := make([]string, 0, len(values))\n\tfor k := range values {\n\t\tkeys = append(keys, k)\n\t}\n\tsort.Strings(keys)\n\n\t// Собираем data_check_string\n\tvar dataCheckArr []string\n\tfor _, k := range keys {\n\t\tdataCheckArr = append(dataCheckArr, fmt.Sprintf(\"%s=%s\", k, values.Get(k)))\n\t}\n\tdataCheckString := strings.Join(dataCheckArr, \"\\n\")\n\n\t// Вычисляем секретный ключ: HMAC-SHA256(\"WebAppData\", token)\n\tsha := sha256.New()\n\tsha.Write([]byte(token))\n\n\tsecretKey := hmac.New(sha256.New, []byte(\"WebAppData\"))\n\tsecretKey.Write([]byte(token))\n\n\t// Вычисляем финальный HMAC\n\th := hmac.New(sha256.New, secretKey.Sum(nil))\n\th.Write([]byte(dataCheckString))\n\texpectedHash := hex.EncodeToString(h.Sum(nil))\n\n\treturn expectedHash == hash, nil\n}\n\n// Упрощенное извлечение ID из JSON-строки поля user\nfunc extractIDFromUserJSON(userJSON string) (int64, error) {\n\t// Ищем \"id\":(\\d+)\n\t// Для надежности в будущем можно использовать json.Unmarshal\n\tstartIdx := strings.Index(userJSON, \"\\\"id\\\":\")\n\tif startIdx == -1 {\n\t\treturn 0, fmt.Errorf(\"id not found\")\n\t}\n\tstartIdx += 5\n\tendIdx := strings.IndexAny(userJSON[startIdx:], \",}\")\n\tif endIdx == -1 {\n\t\treturn 0, fmt.Errorf(\"invalid json\")\n\t}\n\n\tidStr := userJSON[startIdx : startIdx+endIdx]\n\treturn strconv.ParseInt(idStr, 10, 64)\n}\n", - "internal/transport/telegram/bot.go": "// internal/transport/telegram/bot.go\n\npackage telegram\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"go.uber.org/zap\"\n\ttele \"gopkg.in/telebot.v3\"\n\t\"gopkg.in/telebot.v3/middleware\"\n\n\t\"rmser/config\"\n\t\"rmser/internal/domain/account\"\n\t\"rmser/internal/infrastructure/rms\"\n\t\"rmser/internal/services/billing\"\n\t\"rmser/internal/services/ocr\"\n\t\"rmser/internal/services/sync\"\n\t\"rmser/pkg/crypto\"\n\t\"rmser/pkg/logger\"\n)\n\ntype Bot struct {\n\tb *tele.Bot\n\tocrService *ocr.Service\n\tsyncService *sync.Service\n\tbillingService *billing.Service\n\taccountRepo account.Repository\n\trmsFactory *rms.Factory\n\tcryptoManager *crypto.CryptoManager\n\n\tfsm *StateManager\n\tadminIDs map[int64]struct{}\n\tdevIDs map[int64]struct{}\n\tmaintenanceMode bool\n\twebAppURL string\n\n\tmenuServers *tele.ReplyMarkup\n\tmenuDicts *tele.ReplyMarkup\n\tmenuBalance *tele.ReplyMarkup\n}\n\nfunc NewBot(\n\tcfg config.TelegramConfig,\n\tocrService *ocr.Service,\n\tsyncService *sync.Service,\n\tbillingService *billing.Service,\n\taccountRepo account.Repository,\n\trmsFactory *rms.Factory,\n\tcryptoManager *crypto.CryptoManager,\n\tmaintenanceMode bool,\n\tdevIDs []int64,\n) (*Bot, error) {\n\n\tpref := tele.Settings{\n\t\tToken: cfg.Token,\n\t\tPoller: &tele.LongPoller{Timeout: 10 * time.Second},\n\t\tOnError: func(err error, c tele.Context) {\n\t\t\tlogger.Log.Error(\"Telegram error\", zap.Error(err))\n\t\t},\n\t}\n\n\tb, err := tele.NewBot(pref)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tadmins := make(map[int64]struct{})\n\tfor _, id := range cfg.AdminIDs {\n\t\tadmins[id] = struct{}{}\n\t}\n\n\tdevs := make(map[int64]struct{})\n\tfor _, id := range devIDs {\n\t\tdevs[id] = struct{}{}\n\t}\n\n\tbot := &Bot{\n\t\tb: b,\n\t\tocrService: ocrService,\n\t\tsyncService: syncService,\n\t\tbillingService: billingService,\n\t\taccountRepo: accountRepo,\n\t\trmsFactory: rmsFactory,\n\t\tcryptoManager: cryptoManager,\n\t\tfsm: NewStateManager(),\n\t\tadminIDs: admins,\n\t\tdevIDs: devs,\n\t\tmaintenanceMode: maintenanceMode,\n\t\twebAppURL: cfg.WebAppURL,\n\t}\n\n\tif bot.webAppURL == \"\" {\n\t\tbot.webAppURL = \"http://example.com\"\n\t}\n\n\tbot.initMenus()\n\tbot.initHandlers()\n\treturn bot, nil\n}\n\nfunc (bot *Bot) isDev(userID int64) bool {\n\t_, ok := bot.devIDs[userID]\n\treturn !bot.maintenanceMode || ok\n}\n\nfunc (bot *Bot) initMenus() {\n\tbot.menuServers = &tele.ReplyMarkup{}\n\n\tbot.menuDicts = &tele.ReplyMarkup{}\n\tbtnSync := bot.menuDicts.Data(\"⚡️ Быстрое обновление\", \"act_sync\")\n\tbtnFullSync := bot.menuDicts.Data(\"♻️ Полная перезагрузка\", \"act_full_sync\")\n\tbtnBack := bot.menuDicts.Data(\"🔙 Назад\", \"nav_main\")\n\tbot.menuDicts.Inline(\n\t\tbot.menuDicts.Row(btnSync),\n\t\tbot.menuDicts.Row(btnFullSync),\n\t\tbot.menuDicts.Row(btnBack),\n\t)\n\n\tbot.menuBalance = &tele.ReplyMarkup{}\n\t// Кнопки пополнения теперь создаются динамически в renderBalanceMenu\n}\n\nfunc (bot *Bot) initHandlers() {\n\tbot.b.Use(middleware.Logger())\n\tbot.b.Use(bot.registrationMiddleware)\n\n\tbot.b.Handle(\"/start\", bot.handleStartCommand)\n\tbot.b.Handle(\"/admin\", bot.handleAdminCommand)\n\n\tbot.b.Handle(&tele.Btn{Unique: \"nav_main\"}, bot.renderMainMenu)\n\tbot.b.Handle(&tele.Btn{Unique: \"nav_servers\"}, bot.renderServersMenu)\n\tbot.b.Handle(&tele.Btn{Unique: \"nav_dicts\"}, bot.renderDictsMenu)\n\tbot.b.Handle(&tele.Btn{Unique: \"nav_balance\"}, bot.renderBalanceMenu)\n\n\tbot.b.Handle(&tele.Btn{Unique: \"act_add_server\"}, bot.startAddServerFlow)\n\tbot.b.Handle(&tele.Btn{Unique: \"act_sync\"}, bot.triggerSync)\n\tbot.b.Handle(&tele.Btn{Unique: \"act_full_sync\"}, bot.triggerFullSync)\n\tbot.b.Handle(&tele.Btn{Unique: \"act_del_server_menu\"}, bot.renderDeleteServerMenu)\n\tbot.b.Handle(&tele.Btn{Unique: \"confirm_name_yes\"}, bot.handleConfirmNameYes)\n\tbot.b.Handle(&tele.Btn{Unique: \"confirm_name_no\"}, bot.handleConfirmNameNo)\n\n\tbot.b.Handle(&tele.Btn{Unique: \"adm_list_servers\"}, bot.adminListServers)\n\tbot.b.Handle(tele.OnCallback, bot.handleCallback)\n\n\tbot.b.Handle(tele.OnText, bot.handleText)\n\tbot.b.Handle(tele.OnPhoto, bot.handlePhoto)\n}\n\nfunc (bot *Bot) Start() {\n\tlogger.Log.Info(\"Запуск Telegram бота...\")\n\tbot.b.Start()\n}\n\nfunc (bot *Bot) Stop() { bot.b.Stop() }\n\nfunc (bot *Bot) registrationMiddleware(next tele.HandlerFunc) tele.HandlerFunc {\n\treturn func(c tele.Context) error {\n\t\tuser := c.Sender()\n\t\t_, err := bot.accountRepo.GetOrCreateUser(user.ID, user.Username, user.FirstName, user.LastName)\n\t\tif err != nil {\n\t\t\tlogger.Log.Error(\"Failed to register user\", zap.Error(err))\n\t\t}\n\t\treturn next(c)\n\t}\n}\n\nfunc (bot *Bot) handleStartCommand(c tele.Context) error {\n\tpayload := c.Message().Payload\n\tif payload != \"\" && strings.HasPrefix(payload, \"invite_\") {\n\t\treturn bot.handleInviteLink(c, strings.TrimPrefix(payload, \"invite_\"))\n\t}\n\n\tuserID := c.Sender().ID\n\tif bot.maintenanceMode && !bot.isDev(userID) {\n\t\treturn c.Send(\"🛠 **Сервис на техническом обслуживании** Мы проводим плановые работы... 📸 **Вы можете отправить фото чека или накладной** прямо в этот чат — наша команда обработает его и добавит в систему вручную.\", tele.ModeHTML)\n\t}\n\n\twelcomeTxt := \"🚀 RMSer — ваш умный ассистент для iiko\\n\\n\" +\n\t\t\"Больше не нужно вводить накладные вручную. Просто сфотографируйте чек, а я сделаю всё остальное.\\n\\n\" +\n\t\t\"Почему это удобно:\\n\" +\n\t\t\"🧠 Самообучение: Сопоставьте товар один раз, и в следующий раз я узнаю его сам.\\n\" +\n\t\t\"⚙️ Гибкая настройка: Укажите склад по умолчанию и ограничьте область поиска товаров только нужными категориями.\\n\" +\n\t\t\"👥 Работа в команде: Приглашайте сотрудников, распределяйте роли и управляйте доступом прямо в Mini App.\\n\\n\" +\n\t\t\"🎁 Старт без риска: Дарим 10 накладных на 30 дней каждому новому серверу!\"\n\n\treturn bot.renderMainMenuWithText(c, welcomeTxt)\n}\n\n// Вспомогательный метод для рендера (чтобы не дублировать код меню)\nfunc (bot *Bot) renderMainMenuWithText(c tele.Context, text string) error {\n\tbot.fsm.Reset(c.Sender().ID)\n\tuserDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)\n\tactiveServer, _ := bot.accountRepo.GetActiveServer(userDB.ID)\n\n\tmenu := &tele.ReplyMarkup{}\n\tbtnServers := menu.Data(\"🖥 Серверы\", \"nav_servers\")\n\tbtnDicts := menu.Data(\"🔄 Справочники\", \"nav_dicts\")\n\tbtnBalance := menu.Data(\"💰 Баланс\", \"nav_balance\")\n\n\tvar rows []tele.Row\n\trows = append(rows, menu.Row(btnServers, btnDicts))\n\trows = append(rows, menu.Row(btnBalance))\n\n\tif activeServer != nil {\n\t\trole, _ := bot.accountRepo.GetUserRole(userDB.ID, activeServer.ID)\n\t\tif role == account.RoleOwner || role == account.RoleAdmin {\n\t\t\tbtnApp := menu.WebApp(\"📱 Открыть приложение\", &tele.WebApp{URL: bot.webAppURL})\n\t\t\trows = append(rows, menu.Row(btnApp))\n\t\t}\n\t}\n\n\tmenu.Inline(rows...)\n\treturn c.EditOrSend(text, menu, tele.ModeHTML)\n}\n\nfunc (bot *Bot) handleInviteLink(c tele.Context, serverIDStr string) error {\n\tserverID, err := uuid.Parse(serverIDStr)\n\tif err != nil {\n\t\treturn c.Send(\"❌ Некорректная ссылка приглашения.\")\n\t}\n\n\tnewUser := c.Sender()\n\tuserDB, _ := bot.accountRepo.GetOrCreateUser(newUser.ID, newUser.Username, newUser.FirstName, newUser.LastName)\n\n\terr = bot.accountRepo.AddUserToServer(serverID, userDB.ID, account.RoleOperator)\n\tif err != nil {\n\t\treturn c.Send(fmt.Sprintf(\"❌ Не удалось подключиться к серверу: %v\", err))\n\t}\n\n\tbot.rmsFactory.ClearCacheForUser(userDB.ID)\n\n\tactiveServer, err := bot.accountRepo.GetActiveServer(userDB.ID)\n\tif err != nil || activeServer == nil || activeServer.ID != serverID {\n\t\treturn c.Send(\"✅ Доступ предоставлен, но сервер не стал активным автоматически. Выберите его в меню.\")\n\t}\n\n\trole, _ := bot.accountRepo.GetUserRole(userDB.ID, serverID)\n\n\tc.Send(fmt.Sprintf(\"✅ Вы подключены к серверу %s.\\nВаша роль: %s.\\nТеперь вы можете загружать чеки.\", activeServer.Name, role), tele.ModeHTML)\n\n\tif role != account.RoleOwner {\n\t\tgo func() {\n\t\t\tusers, err := bot.accountRepo.GetServerUsers(serverID)\n\t\t\tif err == nil {\n\t\t\t\tfor _, u := range users {\n\t\t\t\t\tif u.Role == account.RoleOwner {\n\t\t\t\t\t\tif u.UserID == userDB.ID {\n\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t}\n\t\t\t\t\t\tname := newUser.FirstName\n\t\t\t\t\t\tif newUser.LastName != \"\" {\n\t\t\t\t\t\t\tname += \" \" + newUser.LastName\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif newUser.Username != \"\" {\n\t\t\t\t\t\t\tname += fmt.Sprintf(\" (@%s)\", newUser.Username)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tmsg := fmt.Sprintf(\"🔔 Обновление команды\\n\\nПользователь %s активировал приглашение на сервер «%s» (Роль: %s).\", name, activeServer.Name, role)\n\t\t\t\t\t\tbot.b.Send(&tele.User{ID: u.User.TelegramID}, msg, tele.ModeHTML)\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}()\n\t}\n\n\treturn bot.renderMainMenu(c)\n}\n\nfunc (bot *Bot) SendRoleChangeNotification(telegramID int64, serverName string, newRole string) {\n\tmsg := fmt.Sprintf(\"ℹ️ Изменение прав доступа\\n\\nСервер: %s\\nВаша новая роль: %s\", serverName, newRole)\n\tbot.b.Send(&tele.User{ID: telegramID}, msg, tele.ModeHTML)\n}\n\nfunc (bot *Bot) SendRemovalNotification(telegramID int64, serverName string) {\n\tmsg := fmt.Sprintf(\"⛔ Доступ закрыт\\n\\nВы были отключены от сервера %s.\", serverName)\n\tbot.b.Send(&tele.User{ID: telegramID}, msg, tele.ModeHTML)\n}\n\nfunc (bot *Bot) handleAdminCommand(c tele.Context) error {\n\tuserID := c.Sender().ID\n\tif _, isAdmin := bot.adminIDs[userID]; !isAdmin {\n\t\treturn nil\n\t}\n\tmenu := &tele.ReplyMarkup{}\n\tbtnServers := menu.Data(\"🏢 Список серверов\", \"adm_list_servers\")\n\tmenu.Inline(menu.Row(btnServers))\n\treturn c.Send(\"🕵️‍♂️ Super Admin Panel\\n\\nВыберите действие:\", menu, tele.ModeHTML)\n}\n\nfunc (bot *Bot) adminListServers(c tele.Context) error {\n\tservers, err := bot.accountRepo.GetAllServersSystemWide()\n\tif err != nil {\n\t\treturn c.Send(\"Error: \" + err.Error())\n\t}\n\tmenu := &tele.ReplyMarkup{}\n\tvar rows []tele.Row\n\tfor _, s := range servers {\n\t\tbtn := menu.Data(fmt.Sprintf(\"🖥 %s\", s.Name), \"adm_srv_\"+s.ID.String())\n\t\trows = append(rows, menu.Row(btn))\n\t}\n\tmenu.Inline(rows...)\n\treturn c.EditOrSend(\"Все серверы системы:\", menu, tele.ModeHTML)\n}\n\nfunc (bot *Bot) renderMainMenu(c tele.Context) error {\n\tbot.fsm.Reset(c.Sender().ID)\n\tuserDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)\n\tactiveServer, _ := bot.accountRepo.GetActiveServer(userDB.ID)\n\n\tmenu := &tele.ReplyMarkup{}\n\tbtnServers := menu.Data(\"🖥 Серверы\", \"nav_servers\")\n\tbtnDicts := menu.Data(\"🔄 Справочники\", \"nav_dicts\")\n\tbtnBalance := menu.Data(\"💰 Баланс\", \"nav_balance\")\n\n\tvar rows []tele.Row\n\trows = append(rows, menu.Row(btnServers, btnDicts))\n\trows = append(rows, menu.Row(btnBalance))\n\n\tshowApp := false\n\tif activeServer != nil {\n\t\trole, _ := bot.accountRepo.GetUserRole(userDB.ID, activeServer.ID)\n\t\tif role == account.RoleOwner || role == account.RoleAdmin {\n\t\t\tshowApp = true\n\t\t}\n\t}\n\n\tif showApp {\n\t\tbtnApp := menu.WebApp(\"📱 Открыть приложение\", &tele.WebApp{URL: bot.webAppURL})\n\t\trows = append(rows, menu.Row(btnApp))\n\t}\n\n\tmenu.Inline(rows...)\n\ttxt := \"👋 Панель управления RMSER\\n\\n\" +\n\t\t\"Здесь вы можете управлять подключенными серверами iiko и следить за актуальностью справочников.\"\n\n\tif activeServer != nil {\n\t\trole, _ := bot.accountRepo.GetUserRole(userDB.ID, activeServer.ID)\n\t\ttxt += fmt.Sprintf(\"\\n\\nАктивный сервер: %s (%s)\", activeServer.Name, role)\n\t}\n\n\treturn c.EditOrSend(txt, menu, tele.ModeHTML)\n}\n\nfunc (bot *Bot) renderServersMenu(c tele.Context) error {\n\tuserDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)\n\tservers, err := bot.accountRepo.GetAllAvailableServers(userDB.ID)\n\tif err != nil {\n\t\treturn c.Send(\"Ошибка БД: \" + err.Error())\n\t}\n\tactiveServer, _ := bot.accountRepo.GetActiveServer(userDB.ID)\n\n\tmenu := &tele.ReplyMarkup{}\n\tvar rows []tele.Row\n\tfor _, s := range servers {\n\t\ticon := \"🔴\"\n\t\tif activeServer != nil && activeServer.ID == s.ID {\n\t\t\ticon = \"🟢\"\n\t\t}\n\t\trole, _ := bot.accountRepo.GetUserRole(userDB.ID, s.ID)\n\t\tlabel := fmt.Sprintf(\"%s %s (%s)\", icon, s.Name, role)\n\t\tbtn := menu.Data(label, \"set_server_\"+s.ID.String())\n\t\trows = append(rows, menu.Row(btn))\n\t}\n\n\tbtnAdd := menu.Data(\"➕ Добавить сервер\", \"act_add_server\")\n\tbtnDel := menu.Data(\"⚙️ Управление / Удаление\", \"act_del_server_menu\")\n\tbtnBack := menu.Data(\"🔙 Назад\", \"nav_main\")\n\n\trows = append(rows, menu.Row(btnAdd, btnDel))\n\trows = append(rows, menu.Row(btnBack))\n\tmenu.Inline(rows...)\n\n\ttxt := fmt.Sprintf(\"🖥 Ваши серверы (%d):\\n\\nНажмите на сервер, чтобы сделать его активным.\", len(servers))\n\treturn c.EditOrSend(txt, menu, tele.ModeHTML)\n}\n\nfunc (bot *Bot) renderDictsMenu(c tele.Context) error {\n\tuserDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)\n\tstats, err := bot.syncService.GetSyncStats(userDB.ID)\n\tvar txt string\n\tif err != nil {\n\t\ttxt = fmt.Sprintf(\"⚠️ Статус: Ошибка или нет активного сервера (%v)\", err)\n\t} else {\n\t\tlastUpdate := \"—\"\n\t\tif stats.LastInvoice != nil {\n\t\t\tlastUpdate = stats.LastInvoice.Format(\"02.01.2006\")\n\t\t}\n\t\ttxt = fmt.Sprintf(\"🔄 Состояние справочников\\n\\n\"+\n\t\t\t\"🏢 Сервер: %s\\n\"+\n\t\t\t\"📦 Товары: %d\\n\"+\n\t\t\t\"🚚 Поставщики: %d\\n\"+\n\t\t\t\"🏭 Склады: %d\\n\\n\"+\n\t\t\t\"📄 Накладные (30дн): %d\\n\"+\n\t\t\t\"📅 Посл. документ: %s\\n\\n\"+\n\t\t\t\"Нажмите «Обновить», чтобы синхронизировать данные.\",\n\t\t\tstats.ServerName, stats.ProductsCount, stats.SuppliersCount, stats.StoresCount, stats.InvoicesLast30, lastUpdate)\n\t}\n\treturn c.EditOrSend(txt, bot.menuDicts, tele.ModeHTML)\n}\n\nfunc (bot *Bot) renderBalanceMenu(c tele.Context) error {\n\tuserDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)\n\tactiveServer, _ := bot.accountRepo.GetActiveServer(userDB.ID)\n\n\ttxt := \"💰 Баланс и Тарифы\\n\\n\"\n\tif activeServer == nil {\n\t\ttxt += \"❌ У вас нет активного сервера. Сначала подключите сервер в меню «Серверы».\"\n\t} else {\n\t\tpaidUntil := \"не активно\"\n\t\tif activeServer.PaidUntil != nil {\n\t\t\tpaidUntil = activeServer.PaidUntil.Format(\"02.01.2006\")\n\t\t}\n\t\ttxt += fmt.Sprintf(\"🏢 Сервер: %s\\n\", activeServer.Name)\n\t\ttxt += fmt.Sprintf(\"📄 Остаток накладных: %d шт.\\n\", activeServer.Balance)\n\t\ttxt += fmt.Sprintf(\"📅 Доступен до: %s\\n\\n\", paidUntil)\n\t\ttxt += \"Выберите способ пополнения:\"\n\t}\n\n\tmenu := &tele.ReplyMarkup{}\n\tvar rows []tele.Row\n\tif activeServer != nil {\n\t\tbtnTopUp := menu.Data(\"💳 Пополнить баланс\", \"bill_topup\")\n\t\tbtnGift := menu.Data(\"🎁 Подарок другу\", \"bill_gift\")\n\t\trows = append(rows, menu.Row(btnTopUp, btnGift))\n\t}\n\tbtnBack := menu.Data(\"🔙 Назад\", \"nav_main\")\n\trows = append(rows, menu.Row(btnBack))\n\tmenu.Inline(rows...)\n\n\treturn c.EditOrSend(txt, menu, tele.ModeHTML)\n}\n\nfunc (bot *Bot) renderTariffShowcase(c tele.Context, targetURL string) error {\n\ttariffs := bot.billingService.GetTariffs()\n\tmenu := &tele.ReplyMarkup{}\n\ttxt := \"🛒 Выберите тарифный план\\n\"\n\tif targetURL != \"\" {\n\t\ttxt += fmt.Sprintf(\"🎁 Оформление подарка для сервера: %s\\n\", targetURL)\n\t}\n\ttxt += \"\\nПакеты (разово):\"\n\n\tvar rows []tele.Row\n\tfor _, t := range tariffs {\n\t\tlabel := fmt.Sprintf(\"%s — %.0f₽ (%d шт)\", t.Name, t.Price, t.InvoicesCount)\n\t\tbtn := menu.Data(label, \"buy_id_\"+t.ID)\n\t\trows = append(rows, menu.Row(btn))\n\t}\n\tbtnBack := menu.Data(\"🔙 Отмена\", \"nav_balance\")\n\trows = append(rows, menu.Row(btnBack))\n\tmenu.Inline(rows...)\n\treturn c.EditOrSend(txt, menu, tele.ModeHTML)\n}\n\nfunc (bot *Bot) handleCallback(c tele.Context) error {\n\tdata := c.Callback().Data\n\tif len(data) > 0 && data[0] == '\\f' {\n\t\tdata = data[1:]\n\t}\n\n\tuserDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)\n\tuserID := c.Sender().ID\n\tif bot.maintenanceMode && !bot.isDev(userID) {\n\t\treturn c.Respond(&tele.CallbackResponse{Text: \"Сервис на обслуживании\"})\n\t}\n\n\t// --- INTEGRATION: Billing Callbacks ---\n\tif strings.HasPrefix(data, \"bill_\") || strings.HasPrefix(data, \"buy_id_\") || strings.HasPrefix(data, \"pay_\") {\n\t\treturn bot.handleBillingCallbacks(c, data, userDB)\n\t}\n\n\tif strings.HasPrefix(data, \"set_server_\") {\n\t\tserverIDStr := strings.TrimPrefix(data, \"set_server_\")\n\t\tserverIDStr = strings.TrimSpace(serverIDStr)\n\t\tif idx := strings.Index(serverIDStr, \"|\"); idx != -1 {\n\t\t\tserverIDStr = serverIDStr[:idx]\n\t\t}\n\t\ttargetID := parseUUID(serverIDStr)\n\t\tif err := bot.accountRepo.SetActiveServer(userDB.ID, targetID); err != nil {\n\t\t\tlogger.Log.Error(\"Failed to set active server\", zap.Error(err))\n\t\t\treturn c.Respond(&tele.CallbackResponse{Text: \"Ошибка: доступ запрещен\"})\n\t\t}\n\t\tbot.rmsFactory.ClearCacheForUser(userDB.ID)\n\t\tc.Respond(&tele.CallbackResponse{Text: \"✅ Сервер выбран\"})\n\t\treturn bot.renderServersMenu(c)\n\t}\n\n\tif strings.HasPrefix(data, \"do_del_server_\") {\n\t\tserverIDStr := strings.TrimPrefix(data, \"do_del_server_\")\n\t\tserverIDStr = strings.TrimSpace(serverIDStr)\n\t\tif idx := strings.Index(serverIDStr, \"|\"); idx != -1 {\n\t\t\tserverIDStr = serverIDStr[:idx]\n\t\t}\n\t\ttargetID := parseUUID(serverIDStr)\n\t\trole, err := bot.accountRepo.GetUserRole(userDB.ID, targetID)\n\t\tif err != nil {\n\t\t\treturn c.Respond(&tele.CallbackResponse{Text: \"Ошибка прав доступа\"})\n\t\t}\n\t\tif role == account.RoleOwner {\n\t\t\tif err := bot.accountRepo.DeleteServer(targetID); err != nil {\n\t\t\t\treturn c.Respond(&tele.CallbackResponse{Text: \"Ошибка удаления\"})\n\t\t\t}\n\t\t\tbot.rmsFactory.ClearCacheForUser(userDB.ID)\n\t\t\tc.Respond(&tele.CallbackResponse{Text: \"Сервер полностью удален\"})\n\t\t} else {\n\t\t\tif err := bot.accountRepo.RemoveUserFromServer(targetID, userDB.ID); err != nil {\n\t\t\t\treturn c.Respond(&tele.CallbackResponse{Text: \"Ошибка выхода\"})\n\t\t\t}\n\t\t\tbot.rmsFactory.ClearCacheForUser(userDB.ID)\n\t\t\tc.Respond(&tele.CallbackResponse{Text: \"Вы покинули сервер\"})\n\t\t}\n\t\tactive, _ := bot.accountRepo.GetActiveServer(userDB.ID)\n\t\tif active == nil {\n\t\t\tall, _ := bot.accountRepo.GetAllAvailableServers(userDB.ID)\n\t\t\tif len(all) > 0 {\n\t\t\t\t_ = bot.accountRepo.SetActiveServer(userDB.ID, all[0].ID)\n\t\t\t}\n\t\t}\n\t\treturn bot.renderDeleteServerMenu(c)\n\t}\n\n\tif strings.HasPrefix(data, \"gen_invite_\") {\n\t\tserverIDStr := strings.TrimPrefix(data, \"gen_invite_\")\n\t\tlink := fmt.Sprintf(\"https://t.me/%s?start=invite_%s\", bot.b.Me.Username, serverIDStr)\n\t\tc.Respond()\n\t\treturn c.Send(fmt.Sprintf(\"🔗 Ссылка для приглашения:\\n\\n%s\\n\\nОтправьте её сотруднику.\", link), tele.ModeHTML)\n\t}\n\n\tif strings.HasPrefix(data, \"adm_srv_\") {\n\t\tserverIDStr := strings.TrimPrefix(data, \"adm_srv_\")\n\t\tserverID := parseUUID(serverIDStr)\n\t\treturn bot.renderServerUsers(c, serverID)\n\t}\n\n\tif strings.HasPrefix(data, \"adm_usr_\") {\n\t\tconnIDStr := strings.TrimPrefix(data, \"adm_usr_\")\n\t\tconnID := parseUUID(connIDStr)\n\t\tlink, err := bot.accountRepo.GetConnectionByID(connID)\n\t\tif err != nil {\n\t\t\treturn c.Respond(&tele.CallbackResponse{Text: \"Ошибка: связь не найдена\"})\n\t\t}\n\t\tif link.Role == account.RoleOwner {\n\t\t\treturn c.Respond(&tele.CallbackResponse{Text: \"Этот пользователь уже Владелец\"})\n\t\t}\n\t\tmenu := &tele.ReplyMarkup{}\n\t\tbtnYes := menu.Data(\"✅ Сделать Владельцем\", fmt.Sprintf(\"adm_own_yes_%s\", link.ID.String()))\n\t\tbtnNo := menu.Data(\"Отмена\", \"adm_srv_\"+link.ServerID.String())\n\t\tmenu.Inline(menu.Row(btnYes), menu.Row(btnNo))\n\t\ttxt := fmt.Sprintf(\"⚠️ Внимание!\\n\\nВы собираетесь передать права Владельца сервера %s пользователю %s.\\n\\nТекущий владелец станет Администратором.\",\n\t\t\tlink.Server.Name, link.User.FirstName)\n\t\treturn c.EditOrSend(txt, menu, tele.ModeHTML)\n\t}\n\n\tif strings.HasPrefix(data, \"adm_own_yes_\") {\n\t\tconnIDStr := strings.TrimPrefix(data, \"adm_own_yes_\")\n\t\tconnID := parseUUID(connIDStr)\n\t\tlink, err := bot.accountRepo.GetConnectionByID(connID)\n\t\tif err != nil {\n\t\t\treturn c.Respond(&tele.CallbackResponse{Text: \"Ошибка: связь не найдена\"})\n\t\t}\n\t\tif err := bot.accountRepo.TransferOwnership(link.ServerID, link.UserID); err != nil {\n\t\t\tlogger.Log.Error(\"Ownership transfer failed\", zap.Error(err))\n\t\t\treturn c.Respond(&tele.CallbackResponse{Text: \"Ошибка: \" + err.Error()})\n\t\t}\n\t\tgo func() {\n\t\t\tmsg := fmt.Sprintf(\"👑 Поздравляем!\\n\\nВам переданы права Владельца (OWNER) сервера %s.\", link.Server.Name)\n\t\t\tbot.b.Send(&tele.User{ID: link.User.TelegramID}, msg, tele.ModeHTML)\n\t\t}()\n\t\tc.Respond(&tele.CallbackResponse{Text: \"Успешно!\"})\n\t\treturn bot.renderServerUsers(c, link.ServerID)\n\t}\n\n\treturn nil\n}\n\n// Реализация метода интерфейса PaymentNotifier\nfunc (bot *Bot) NotifySuccess(userID uuid.UUID, amount float64, newBalance int, serverName string) {\n\n\tuser, err := bot.accountRepo.GetUserByID(userID)\n\tif err != nil {\n\t\tlogger.Log.Error(\"Failed to find user for payment notification\", zap.Error(err))\n\t\treturn\n\t}\n\n\tmsg := fmt.Sprintf(\n\t\t\"✅ Оплата получена!\\n\\n\"+\n\t\t\t\"Сумма: %.2f ₽\\n\"+\n\t\t\t\"Сервер: %s\\n\"+\n\t\t\t\"Текущий баланс: %d накладных\\n\\n\"+\n\t\t\t\"Спасибо за использование RMSer!\",\n\t\tamount, serverName, newBalance,\n\t)\n\n\tbot.b.Send(&tele.User{ID: user.TelegramID}, msg, tele.ModeHTML)\n}\n\nfunc (bot *Bot) triggerFullSync(c tele.Context) error {\n\tuserDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)\n\tserver, _ := bot.accountRepo.GetActiveServer(userDB.ID)\n\tif server == nil {\n\t\treturn c.Respond(&tele.CallbackResponse{Text: \"Нет активного сервера\"})\n\t}\n\n\tc.Respond(&tele.CallbackResponse{Text: \"Запущена полная перезагрузка данных...\", ShowAlert: false})\n\tc.Send(\"⏳ Полная синхронизация\\\\nОбновляю историю накладных за 60 дней и справочники. Это может занять до 1 минуты.\", tele.ModeHTML)\n\n\tgo func() {\n\t\tif err := bot.syncService.SyncAllData(userDB.ID, true); err != nil {\n\t\t\tbot.b.Send(c.Sender(), \"❌ Ошибка при полной синхронизации: \"+err.Error())\n\t\t} else {\n\t\t\tbot.b.Send(c.Sender(), \"✅ Все данные успешно обновлены!\")\n\t\t}\n\t}()\n\treturn nil\n}\n\nfunc (bot *Bot) handleBillingCallbacks(c tele.Context, data string, userDB *account.User) error {\n\tif data == \"bill_topup\" {\n\t\treturn bot.renderTariffShowcase(c, \"\")\n\t}\n\n\tif data == \"bill_gift\" {\n\t\tbot.fsm.SetState(c.Sender().ID, StateBillingGiftURL)\n\t\treturn c.EditOrSend(\"🎁 Режим подарка\\n\\nВведите URL сервера, который хотите пополнить (например: https://myresto.iiko.it).\\n\\nСервер должен быть уже зарегистрирован в нашей системе.\", tele.ModeHTML)\n\t}\n\n\tif strings.HasPrefix(data, \"pay_confirm_\") {\n\t\torderIDStr := strings.TrimPrefix(data, \"pay_confirm_\")\n\t\torderID, _ := uuid.Parse(orderIDStr)\n\t\tif err := bot.billingService.ConfirmOrder(orderID); err != nil {\n\t\t\treturn c.Respond(&tele.CallbackResponse{Text: \"Ошибка: \" + err.Error()})\n\t\t}\n\t\tc.Respond(&tele.CallbackResponse{Text: \"✨ Оплата успешно имитирована!\"})\n\t\tbot.fsm.Reset(c.Sender().ID)\n\t\treturn c.EditOrSend(\"✅ Оплата прошла успешно!\\n\\nУслуги начислены на баланс сервера. Теперь вы можете продолжить работу с накладными.\", bot.menuBalance, tele.ModeHTML)\n\t}\n\n\tif strings.HasPrefix(data, \"buy_id_\") {\n\t\ttariffID := strings.TrimPrefix(data, \"buy_id_\")\n\t\tctxFSM := bot.fsm.GetContext(c.Sender().ID)\n\n\t\t// 1. Формируем URL возврата (ссылка на бота)\n\t\treturnURL := fmt.Sprintf(\"https://t.me/%s\", bot.b.Me.Username)\n\n\t\t// 2. Вызываем обновленный метод (теперь возвращает 3 значения)\n\t\torder, payURL, err := bot.billingService.CreateOrder(\n\t\t\tcontext.Background(),\n\t\t\tuserDB.ID,\n\t\t\ttariffID,\n\t\t\tctxFSM.BillingTargetURL,\n\t\t\treturnURL,\n\t\t)\n\t\tif err != nil {\n\t\t\treturn c.Send(\"❌ Ошибка при формировании счета: \" + err.Error())\n\t\t}\n\n\t\t// 3. Создаем кнопку с URL на ЮКассу\n\t\tmenu := &tele.ReplyMarkup{}\n\t\tbtnPay := menu.URL(\"💳 Оплатить\", payURL) // payURL теперь string\n\t\tbtnBack := menu.Data(\"🔙 Отмена\", \"nav_balance\")\n\t\tmenu.Inline(menu.Row(btnPay), menu.Row(btnBack))\n\n\t\ttxt := fmt.Sprintf(\n\t\t\t\"📦 Заказ №%s\\n\\nСумма к оплате: %.2f ₽\\n\\nНажмите кнопку ниже для перехода к оплате. Баланс будет пополнен автоматически сразу после подтверждения платежа.\",\n\t\t\torder.ID.String()[:8], // Показываем короткий ID для красоты\n\t\t\torder.Amount,\n\t\t)\n\t\treturn c.EditOrSend(txt, menu, tele.ModeHTML)\n\t}\n\n\treturn nil\n}\n\nfunc (bot *Bot) renderServerUsers(c tele.Context, serverID uuid.UUID) error {\n\tusers, err := bot.accountRepo.GetServerUsers(serverID)\n\tif err != nil {\n\t\treturn c.Respond(&tele.CallbackResponse{Text: \"Ошибка загрузки юзеров\"})\n\t}\n\tmenu := &tele.ReplyMarkup{}\n\tvar rows []tele.Row\n\tfor _, u := range users {\n\t\troleIcon := \"👤\"\n\t\tif u.Role == account.RoleOwner {\n\t\t\troleIcon = \"👑\"\n\t\t}\n\t\tif u.Role == account.RoleAdmin {\n\t\t\troleIcon = \"⭐️\"\n\t\t}\n\t\tlabel := fmt.Sprintf(\"%s %s %s\", roleIcon, u.User.FirstName, u.User.LastName)\n\t\tpayload := fmt.Sprintf(\"adm_usr_%s\", u.ID.String())\n\t\tbtn := menu.Data(label, payload)\n\t\trows = append(rows, menu.Row(btn))\n\t}\n\tbtnBack := menu.Data(\"🔙 К серверам\", \"adm_list_servers\")\n\trows = append(rows, menu.Row(btnBack))\n\tmenu.Inline(rows...)\n\tserverName := \"Unknown\"\n\tif len(users) > 0 {\n\t\tserverName = users[0].Server.Name\n\t}\n\treturn c.EditOrSend(fmt.Sprintf(\"👥 Пользователи сервера %s:\", serverName), menu, tele.ModeHTML)\n}\n\nfunc (bot *Bot) triggerSync(c tele.Context) error {\n\tuserDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)\n\tserver, err := bot.accountRepo.GetActiveServer(userDB.ID)\n\tif err != nil || server == nil {\n\t\treturn c.Respond(&tele.CallbackResponse{Text: \"Нет активного сервера\"})\n\t}\n\trole, _ := bot.accountRepo.GetUserRole(userDB.ID, server.ID)\n\tif role == account.RoleOperator {\n\t\treturn c.Respond(&tele.CallbackResponse{Text: \"⚠️ Синхронизация доступна только Админам\", ShowAlert: true})\n\t}\n\tc.Respond(&tele.CallbackResponse{Text: \"Запускаю синхронизацию...\"})\n\tgo func() {\n\t\tif err := bot.syncService.SyncAllData(userDB.ID, false); err != nil {\n\t\t\tlogger.Log.Error(\"Manual sync failed\", zap.Error(err))\n\t\t\tbot.b.Send(c.Sender(), \"❌ Ошибка синхронизации. Проверьте настройки сервера.\")\n\t\t} else {\n\t\t\tbot.b.Send(c.Sender(), \"✅ Синхронизация успешно завершена!\")\n\t\t}\n\t}()\n\treturn nil\n}\n\nfunc (bot *Bot) startAddServerFlow(c tele.Context) error {\n\tbot.fsm.SetState(c.Sender().ID, StateAddServerURL)\n\treturn c.EditOrSend(\"🔗 Введите URL вашего сервера iikoRMS.\\nПример: https://resto.iiko.it\\n\\n(Напишите 'отмена' для выхода)\", tele.ModeHTML)\n}\n\nfunc (bot *Bot) handleText(c tele.Context) error {\n\tuserID := c.Sender().ID\n\tstate := bot.fsm.GetState(userID)\n\ttext := strings.TrimSpace(c.Text())\n\n\tif bot.maintenanceMode && !bot.isDev(userID) {\n\t\treturn c.Send(\"Сервис на обслуживании\", tele.ModeHTML)\n\t}\n\n\tif strings.ToLower(text) == \"отмена\" || strings.ToLower(text) == \"/cancel\" {\n\t\tbot.fsm.Reset(userID)\n\t\treturn bot.renderMainMenu(c)\n\t}\n\n\tif state == StateNone {\n\t\treturn c.Send(\"Используйте меню для навигации 👇\")\n\t}\n\n\tswitch state {\n\tcase StateAddServerURL:\n\t\tif !strings.HasPrefix(text, \"http\") {\n\t\t\treturn c.Send(\"❌ URL должен начинаться с http:// или https://\\nПопробуйте снова.\")\n\t\t}\n\t\tbot.fsm.UpdateContext(userID, func(ctx *UserContext) {\n\t\t\tctx.TempURL = strings.TrimRight(text, \"/\")\n\t\t\tctx.State = StateAddServerLogin\n\t\t})\n\t\treturn c.Send(\"👤 Введите логин пользователя iiko:\")\n\n\tcase StateAddServerLogin:\n\t\tbot.fsm.UpdateContext(userID, func(ctx *UserContext) {\n\t\t\tctx.TempLogin = text\n\t\t\tctx.State = StateAddServerPassword\n\t\t})\n\t\treturn c.Send(\"🔑 Введите пароль:\")\n\n\tcase StateAddServerPassword:\n\t\tpassword := text\n\t\tctx := bot.fsm.GetContext(userID)\n\t\tmsg, _ := bot.b.Send(c.Sender(), \"⏳ Проверяю подключение...\")\n\t\ttempClient := bot.rmsFactory.CreateClientFromRawCredentials(ctx.TempURL, ctx.TempLogin, password)\n\t\tif err := tempClient.Auth(); err != nil {\n\t\t\tbot.b.Delete(msg)\n\t\t\treturn c.Send(fmt.Sprintf(\"❌ Ошибка авторизации: %v\\nПроверьте логин/пароль.\", err))\n\t\t}\n\t\tvar detectedName string\n\t\tinfo, err := rms.GetServerInfo(ctx.TempURL)\n\t\tif err == nil && info.ServerName != \"\" {\n\t\t\tdetectedName = info.ServerName\n\t\t}\n\t\tbot.b.Delete(msg)\n\t\tbot.fsm.UpdateContext(userID, func(uCtx *UserContext) {\n\t\t\tuCtx.TempPassword = password\n\t\t\tuCtx.TempServerName = detectedName\n\t\t})\n\t\tif detectedName != \"\" {\n\t\t\tbot.fsm.SetState(userID, StateAddServerConfirmName)\n\t\t\tmenu := &tele.ReplyMarkup{}\n\t\t\tbtnYes := menu.Data(\"✅ Да, использовать это имя\", \"confirm_name_yes\")\n\t\t\tbtnNo := menu.Data(\"✏️ Ввести другое\", \"confirm_name_no\")\n\t\t\tmenu.Inline(menu.Row(btnYes), menu.Row(btnNo))\n\t\t\treturn c.Send(fmt.Sprintf(\"🔎 Обнаружено имя сервера: %s.\\nИспользовать его?\", detectedName), menu, tele.ModeHTML)\n\t\t}\n\t\tbot.fsm.SetState(userID, StateAddServerInputName)\n\t\treturn c.Send(\"🏷 Введите название для этого сервера:\")\n\n\tcase StateAddServerInputName:\n\t\tname := text\n\t\tif len(name) < 3 {\n\t\t\treturn c.Send(\"⚠️ Название слишком короткое.\")\n\t\t}\n\t\treturn bot.saveServerFinal(c, userID, name)\n\n\tcase StateBillingGiftURL:\n\t\tif !strings.HasPrefix(text, \"http\") {\n\t\t\treturn c.Send(\"❌ Некорректный URL. Он должен начинаться с http:// или https://\")\n\t\t}\n\t\t_, err := bot.accountRepo.GetServerByURL(text)\n\t\tif err != nil {\n\t\t\treturn c.Send(\"🔍 Сервер с таким URL не найден в системе. Попросите владельца сначала подключить его к боту.\")\n\t\t}\n\t\tbot.fsm.UpdateContext(c.Sender().ID, func(ctx *UserContext) {\n\t\t\tctx.BillingTargetURL = text\n\t\t})\n\t\treturn bot.renderTariffShowcase(c, text)\n\t}\n\n\treturn nil\n}\n\nfunc (bot *Bot) handlePhoto(c tele.Context) error {\n\tuserDB, err := bot.accountRepo.GetOrCreateUser(c.Sender().ID, c.Sender().Username, \"\", \"\")\n\tif err != nil {\n\t\treturn c.Send(\"Ошибка базы данных пользователей\")\n\t}\n\tuserID := c.Sender().ID\n\t_, err = bot.rmsFactory.GetClientForUser(userDB.ID)\n\tif err != nil {\n\t\treturn c.Send(\"⛔ Ошибка доступа к iiko или сервер не выбран.\\nПроверьте статус сервера в меню.\")\n\t}\n\tphoto := c.Message().Photo\n\tfile, err := bot.b.FileByID(photo.FileID)\n\tif err != nil {\n\t\treturn c.Send(\"Ошибка доступа к файлу.\")\n\t}\n\tfileURL := fmt.Sprintf(\"https://api.telegram.org/file/bot%s/%s\", bot.b.Token, file.FilePath)\n\tresp, err := http.Get(fileURL)\n\tif err != nil {\n\t\treturn c.Send(\"Ошибка скачивания файла.\")\n\t}\n\tdefer resp.Body.Close()\n\timgData, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn c.Send(\"Ошибка чтения файла.\")\n\t}\n\tc.Send(\"⏳ ИИ анализирует документ...\\nОбрабатываю позиции и ищу совпадения в вашей базе iiko.\", tele.ModeHTML)\n\tctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)\n\tdefer cancel()\n\tdraft, err := bot.ocrService.ProcessReceiptImage(ctx, userDB.ID, imgData)\n\tif err != nil {\n\t\tlogger.Log.Error(\"OCR processing failed\", zap.Error(err))\n\t\treturn c.Send(\"❌ Ошибка обработки: \" + err.Error())\n\t}\n\tmatchedCount := 0\n\tfor _, item := range draft.Items {\n\t\tif item.IsMatched {\n\t\t\tmatchedCount++\n\t\t}\n\t}\n\tif bot.isDev(userID) {\n\t\tbaseURL := strings.TrimRight(bot.webAppURL, \"/\")\n\t\tfullURL := fmt.Sprintf(\"%s/invoice/%s\", baseURL, draft.ID.String())\n\t\tvar msgText string\n\t\tif matchedCount == len(draft.Items) {\n\t\t\tmsgText = fmt.Sprintf(\"✅ Все позиции распознаны!\\n\\nВсе товары найдены в вашей номенклатуре. Проверьте количество (%d) и цену в приложении перед отправкой.\", len(draft.Items))\n\t\t} else {\n\t\t\tmsgText = fmt.Sprintf(\"⚠️ Распознано позиций: %d из %d\\n\\nОстальные товары я вижу впервые. Воспользуйтесь удобным интерфейсом сопоставления в приложении — я запомню ваш выбор навсегда.\", matchedCount, len(draft.Items))\n\t\t}\n\t\tmenu := &tele.ReplyMarkup{}\n\t\trole, _ := bot.accountRepo.GetUserRole(userDB.ID, draft.RMSServerID)\n\t\tif role != account.RoleOperator {\n\t\t\tbtnOpen := menu.WebApp(\"📝 Открыть и сопоставить\", &tele.WebApp{URL: fullURL})\n\t\t\tmenu.Inline(menu.Row(btnOpen))\n\t\t} else {\n\t\t\tmsgText += \"\\n\\n(Редактирование доступно Администратору)\"\n\t\t}\n\t\treturn c.Send(msgText, menu, tele.ModeHTML)\n\t} else {\n\t\treturn c.Send(\"✅ **Фото принято!** Мы получили ваш документ и передали его оператору. Спасибо!\", tele.ModeHTML)\n\t}\n}\n\nfunc (bot *Bot) handleConfirmNameYes(c tele.Context) error {\n\tuserID := c.Sender().ID\n\tctx := bot.fsm.GetContext(userID)\n\tif ctx.State != StateAddServerConfirmName {\n\t\treturn c.Respond()\n\t}\n\treturn bot.saveServerFinal(c, userID, ctx.TempServerName)\n}\n\nfunc (bot *Bot) handleConfirmNameNo(c tele.Context) error {\n\tuserID := c.Sender().ID\n\tbot.fsm.SetState(userID, StateAddServerInputName)\n\treturn c.EditOrSend(\"🏷 Хорошо, введите желаемое название:\")\n}\n\n// Обновленный метод saveServerFinal (добавление уведомления о бонусе)\nfunc (bot *Bot) saveServerFinal(c tele.Context, userID int64, serverName string) error {\n\tctx := bot.fsm.GetContext(userID)\n\tuserDB, _ := bot.accountRepo.GetOrCreateUser(userID, c.Sender().Username, \"\", \"\")\n\tencPass, _ := bot.cryptoManager.Encrypt(ctx.TempPassword)\n\n\tserver, err := bot.accountRepo.ConnectServer(userDB.ID, ctx.TempURL, ctx.TempLogin, encPass, serverName)\n\tif err != nil {\n\t\treturn c.Send(\"❌ Ошибка подключения сервера: \" + err.Error())\n\t}\n\tbot.fsm.Reset(userID)\n\n\trole, _ := bot.accountRepo.GetUserRole(userDB.ID, server.ID)\n\n\tsuccessMsg := fmt.Sprintf(\"✅ Сервер %s успешно подключен!\\nВаша роль: %s\\n\\n\", server.Name, role)\n\n\t// Проверяем, новый ли это сервер по балансу и дате создания (упрощенно для уведомления)\n\tif server.Balance == 10 {\n\t\tsuccessMsg += \"🎁 Вам начислен приветственный бонус: 10 накладных на 30 дней! Пользуйтесь с удовольствием.\\n\\n\"\n\t}\n\n\tsuccessMsg += \"Начинаю первичную синхронизацию данных...\"\n\n\tc.Send(successMsg, tele.ModeHTML)\n\tgo bot.syncService.SyncAllData(userDB.ID, false)\n\n\treturn bot.renderMainMenu(c)\n}\n\nfunc (bot *Bot) renderDeleteServerMenu(c tele.Context) error {\n\tuserDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)\n\tservers, err := bot.accountRepo.GetAllAvailableServers(userDB.ID)\n\tif err != nil {\n\t\treturn c.Send(\"Ошибка БД: \" + err.Error())\n\t}\n\tif len(servers) == 0 {\n\t\treturn c.Respond(&tele.CallbackResponse{Text: \"Список серверов пуст\"})\n\t}\n\tmenu := &tele.ReplyMarkup{}\n\tvar rows []tele.Row\n\tfor _, s := range servers {\n\t\trole, _ := bot.accountRepo.GetUserRole(userDB.ID, s.ID)\n\t\tvar label string\n\t\tif role == account.RoleOwner {\n\t\t\tlabel = fmt.Sprintf(\"❌ Удалить %s (Owner)\", s.Name)\n\t\t} else {\n\t\t\tlabel = fmt.Sprintf(\"🚪 Покинуть %s\", s.Name)\n\t\t}\n\t\tbtnAction := menu.Data(label, \"do_del_server_\"+s.ID.String())\n\t\tif role == account.RoleOwner || role == account.RoleAdmin {\n\t\t\tbtnInvite := menu.Data(fmt.Sprintf(\"📩 Invite %s\", s.Name), \"gen_invite_\"+s.ID.String())\n\t\t\trows = append(rows, menu.Row(btnAction, btnInvite))\n\t\t} else {\n\t\t\trows = append(rows, menu.Row(btnAction))\n\t\t}\n\t}\n\tbtnBack := menu.Data(\"🔙 Назад к списку\", \"nav_servers\")\n\trows = append(rows, menu.Row(btnBack))\n\tmenu.Inline(rows...)\n\treturn c.EditOrSend(\"⚙️ Управление серверами\\n\\nЗдесь вы можете удалить сервер или пригласить сотрудников.\", menu, tele.ModeHTML)\n}\n\n// NotifyDevs отправляет фото разработчикам для отладки\nfunc (bot *Bot) NotifyDevs(devIDs []int64, photoPath string, serverName string, serverID string) {\n\t// Формируем подпись для фото\n\tcaption := fmt.Sprintf(\"🛠 **Debug Capture**\\nServer: %s (`%s`)\\nFile: %s\", serverName, serverID, photoPath)\n\n\t// В цикле отправляем фото каждому разработчику\n\tfor _, id := range devIDs {\n\t\tphoto := &tele.Photo{\n\t\t\tFile: tele.FromDisk(photoPath),\n\t\t\tCaption: caption,\n\t\t}\n\t\t// Отправляем фото пользователю\n\t\t_, err := bot.b.Send(&tele.User{ID: id}, photo)\n\t\tif err != nil {\n\t\t\tlogger.Log.Error(\"Failed to send debug photo\", zap.Int64(\"userID\", id), zap.Error(err))\n\t\t}\n\t}\n}\n\nfunc parseUUID(s string) uuid.UUID {\n\tid, _ := uuid.Parse(s)\n\treturn id\n}\n", - "internal/transport/telegram/fsm.go": "package telegram\n\nimport \"sync\"\n\n// Состояния пользователя\ntype State int\n\nconst (\n\tStateNone State = iota\n\tStateAddServerURL\n\tStateAddServerLogin\n\tStateAddServerPassword\n\tStateAddServerConfirmName\n\tStateAddServerInputName\n\tStateBillingGiftURL\n)\n\n// UserContext хранит временные данные в процессе диалога\ntype UserContext struct {\n\tState State\n\tTempURL string\n\tTempLogin string\n\tTempPassword string\n\tTempServerName string\n\tBillingTargetURL string\n}\n\n// StateManager управляет состояниями\ntype StateManager struct {\n\tmu sync.RWMutex\n\tstates map[int64]*UserContext\n}\n\nfunc NewStateManager() *StateManager {\n\treturn &StateManager{\n\t\tstates: make(map[int64]*UserContext),\n\t}\n}\n\nfunc (sm *StateManager) GetState(userID int64) State {\n\tsm.mu.RLock()\n\tdefer sm.mu.RUnlock()\n\tif ctx, ok := sm.states[userID]; ok {\n\t\treturn ctx.State\n\t}\n\treturn StateNone\n}\n\nfunc (sm *StateManager) SetState(userID int64, state State) {\n\tsm.mu.Lock()\n\tdefer sm.mu.Unlock()\n\n\tif _, ok := sm.states[userID]; !ok {\n\t\tsm.states[userID] = &UserContext{}\n\t}\n\tsm.states[userID].State = state\n}\n\nfunc (sm *StateManager) GetContext(userID int64) *UserContext {\n\tsm.mu.RLock()\n\tdefer sm.mu.RUnlock()\n\tif ctx, ok := sm.states[userID]; ok {\n\t\treturn ctx\n\t}\n\treturn &UserContext{} // Return empty safe struct\n}\n\nfunc (sm *StateManager) UpdateContext(userID int64, updater func(*UserContext)) {\n\tsm.mu.Lock()\n\tdefer sm.mu.Unlock()\n\n\tif _, ok := sm.states[userID]; !ok {\n\t\tsm.states[userID] = &UserContext{}\n\t}\n\tupdater(sm.states[userID])\n}\n\nfunc (sm *StateManager) Reset(userID int64) {\n\tsm.mu.Lock()\n\tdefer sm.mu.Unlock()\n\tdelete(sm.states, userID)\n}\n", - "pkg/crypto/crypto.go": "package crypto\n\nimport (\n\t\"crypto/aes\"\n\t\"crypto/cipher\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\" // <-- Добавлен импорт\n\t\"encoding/base64\"\n\t\"errors\"\n\t\"io\"\n)\n\n// CryptoManager занимается шифрованием чувствительных данных (паролей RMS)\ntype CryptoManager struct {\n\tkey []byte\n}\n\nfunc NewCryptoManager(secretKey string) *CryptoManager {\n\t// Исправление:\n\t// AES требует строго 16, 24 или 32 байта.\n\t// Чтобы не заставлять пользователя считать символы в конфиге,\n\t// мы хешируем любую строку в SHA-256, получая всегда валидные 32 байта.\n\thasher := sha256.New()\n\thasher.Write([]byte(secretKey))\n\tkeyBytes := hasher.Sum(nil)\n\n\treturn &CryptoManager{key: keyBytes}\n}\n\n// Encrypt шифрует строку и возвращает base64\nfunc (m *CryptoManager) Encrypt(plaintext string) (string, error) {\n\tblock, err := aes.NewCipher(m.key)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tgcm, err := cipher.NewGCM(block)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tnonce := make([]byte, gcm.NonceSize())\n\tif _, err := io.ReadFull(rand.Reader, nonce); err != nil {\n\t\treturn \"\", err\n\t}\n\n\tciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)\n\treturn base64.StdEncoding.EncodeToString(ciphertext), nil\n}\n\n// Decrypt расшифровывает base64 строку\nfunc (m *CryptoManager) Decrypt(ciphertextBase64 string) (string, error) {\n\tdata, err := base64.StdEncoding.DecodeString(ciphertextBase64)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tblock, err := aes.NewCipher(m.key)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tgcm, err := cipher.NewGCM(block)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tnonceSize := gcm.NonceSize()\n\tif len(data) < nonceSize {\n\t\treturn \"\", errors.New(\"ciphertext too short\")\n\t}\n\n\tnonce, ciphertext := data[:nonceSize], data[nonceSize:]\n\tplaintext, err := gcm.Open(nil, nonce, ciphertext, nil)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn string(plaintext), nil\n}\n", - "pkg/logger/logger.go": "package logger\n\nimport (\n\t\"go.uber.org/zap\"\n\t\"go.uber.org/zap/zapcore\"\n)\n\nvar Log *zap.Logger\n\nfunc Init(mode string) {\n\tvar config zap.Config\n\n\tif mode == \"release\" {\n\t\tconfig = zap.NewProductionConfig()\n\t} else {\n\t\tconfig = zap.NewDevelopmentConfig()\n\t\tconfig.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder\n\t}\n\n\tvar err error\n\tLog, err = config.Build()\n\tif err != nil {\n\t\tpanic(\"не удалось инициализировать логгер: \" + err.Error())\n\t}\n}", -} - -if __name__ == '__main__': - print('=== Дерево проекта ===') - print(project_tree) - print('\n=== Список файлов ===') - for name in project_files: - print(f'- {name}')