Files
rmser/go_backend_dump.py

185 lines
302 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- 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(&currentCount)\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(&currentOwnerLink).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(&currentOwnerLink).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<UUID>\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 := \"🚀 <b>RMSer — ваш умный ассистент для iiko</b>\\n\\n\" +\n\t\t\"Больше не нужно вводить накладные вручную. Просто сфотографируйте чек, а я сделаю всё остальное.\\n\\n\" +\n\t\t\"<b>Почему это удобно:</b>\\n\" +\n\t\t\"🧠 <b>Самообучение:</b> Сопоставьте товар один раз, и в следующий раз я узнаю его сам.\\n\" +\n\t\t\"⚙️ <b>Гибкая настройка:</b> Укажите склад по умолчанию и ограничьте область поиска товаров только нужными категориями.\\n\" +\n\t\t\"👥 <b>Работа в команде:</b> Приглашайте сотрудников, распределяйте роли и управляйте доступом прямо в Mini App.\\n\\n\" +\n\t\t\"🎁 <b>Старт без риска:</b> Дарим 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(\"✅ Вы подключены к серверу <b>%s</b>.\\nВаша роль: <b>%s</b>.\\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(\"🔔 <b>Обновление команды</b>\\n\\ользователь <b>%s</b> активировал приглашение на сервер «%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(\" <b>Изменение прав доступа</b>\\n\\nСервер: <b>%s</b>\\nВаша новая роль: <b>%s</b>\", 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(\"⛔ <b>Доступ закрыт</b>\\n\\nВы были отключены от сервера <b>%s</b>.\", 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(\"🕵️‍♂️ <b>Super Admin Panel</b>\\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(\"<b>Все серверы системы:</b>\", 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 := \"👋 <b>Панель управления RMSER</b>\\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Активный сервер: <b>%s</b> (%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(\"<b>🖥 Ваши серверы (%d):</b>\\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(\"⚠️ <b>Статус:</b> Ошибка или нет активного сервера (%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(\"<b>🔄 Состояние справочников</b>\\n\\n\"+\n\t\t\t\"🏢 <b>Сервер:</b> %s\\n\"+\n\t\t\t\"📦 <b>Товары:</b> %d\\n\"+\n\t\t\t\"🚚 <b>Поставщики:</b> %d\\n\"+\n\t\t\t\"🏭 <b>Склады:</b> %d\\n\\n\"+\n\t\t\t\"📄 <b>Накладные (30дн):</b> %d\\n\"+\n\t\t\t\"📅 <b>Посл. документ:</b> %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 := \"<b>💰 Баланс и Тарифы</b>\\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(\"🏢 Сервер: <b>%s</b>\\n\", activeServer.Name)\n\t\ttxt += fmt.Sprintf(\"📄 Остаток накладных: <b>%d шт.</b>\\n\", activeServer.Balance)\n\t\ttxt += fmt.Sprintf(\"📅 Доступен до: <b>%s</b>\\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 := \"<b>🛒 Выберите тарифный план</b>\\n\"\n\tif targetURL != \"\" {\n\t\ttxt += fmt.Sprintf(\"🎁 Оформление подарка для сервера: <code>%s</code>\\n\", targetURL)\n\t}\n\ttxt += \"\\n<b>Пакеты (разово):</b>\"\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(\"🔗 <b>Ссылка для приглашения:</b>\\n\\n<code>%s</code>\\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(\"⚠️ <b>Внимание!</b>\\n\\nВы собираетесь передать права Владельца сервера <b>%s</b> пользователю <b>%s</b>.\\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(\"👑 <b>Поздравляем!</b>\\n\\nВам переданы права Владельца (OWNER) сервера <b>%s</b>.\", 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\"✅ <b>Оплата получена!</b>\\n\\n\"+\n\t\t\t\"Сумма: <b>%.2f ₽</b>\\n\"+\n\t\t\t\"Сервер: <b>%s</b>\\n\"+\n\t\t\t\"Текущий баланс: <b>%d накладных</b>\\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(\"⏳ <b>Полная синхронизация</b>\\\\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(\"🎁 <b>Режим подарка</b>\\n\\nВведите URL сервера, который хотите пополнить (например: <code>https://myresto.iiko.it</code>).\\n\\n<i>Сервер должен быть уже зарегистрирован в нашей системе.</i>\", 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(\"✅ <b>Оплата прошла успешно!</b>\\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\"📦 <b>Заказ №%s</b>\\n\\nСумма к оплате: <b>%.2f ₽</b>\\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(\"👥 Пользователи сервера <b>%s</b>:\", 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(\"🔗 Введите <b>URL</b> вашего сервера iikoRMS.\\ример: <code>https://resto.iiko.it</code>\\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\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\\роверьте логин/пароль.\", 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(\"🔎 Обнаружено имя сервера: <b>%s</b>.\\спользовать его?\", detectedName), menu, tele.ModeHTML)\n\t\t}\n\t\tbot.fsm.SetState(userID, StateAddServerInputName)\n\t\treturn c.Send(\"🏷 Введите <b>название</b> для этого сервера:\")\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\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(\"⏳ <b>ИИ анализирует документ...</b>\\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(\"✅ <b>Все позиции распознаны!</b>\\n\\nВсе товары найдены в вашей номенклатуре. Проверьте количество (%d) и цену в приложении перед отправкой.\", len(draft.Items))\n\t\t} else {\n\t\t\tmsgText = fmt.Sprintf(\"⚠️ <b>Распознано позиций: %d из %d</b>\\n\\nОстальные товары я вижу впервые. Воспользуйтесь <b>удобным интерфейсом сопоставления</b> в приложении — я запомню ваш выбор навсегда.\", 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<i>(Редактирование доступно Администратору)</i>\"\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(\"🏷 Хорошо, введите желаемое <b>название</b>:\")\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(\"✅ Сервер <b>%s</b> успешно подключен!\\nВаша роль: <b>%s</b>\\n\\n\", server.Name, role)\n\n\t// Проверяем, новый ли это сервер по балансу и дате создания (упрощенно для уведомления)\n\tif server.Balance == 10 {\n\t\tsuccessMsg += \"🎁 Вам начислен <b>приветственный бонус: 10 накладных</b> на 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(\"⚙️ <b>Управление серверами</b>\\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}')