From 1843cb9c205abafee4f305654f0501844f9a5743 Mon Sep 17 00:00:00 2001 From: SERTY Date: Tue, 27 Jan 2026 00:17:10 +0300 Subject: [PATCH] =?UTF-8?q?2612-=D0=B5=D1=81=D1=82=D1=8C=20=D0=BE=D0=BA=20?= =?UTF-8?q?OCR,=20=D0=BD=D1=83=D0=B6=D0=BD=D0=BE=20=D0=B4=D0=BE=D0=BF?= =?UTF-8?q?=D0=B8=D0=BB=D0=B8=D0=B2=D0=B0=D1=82=D1=8C=20=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BE=D0=B4=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B9=20?= =?UTF-8?q?flow=20=D0=B4=D0=BB=D1=8F=20=D0=BE=D0=BF=D0=B5=D1=80=D0=B0?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/main.go | 2 +- go_backend_dump.py | 184 ++++++++++++++ internal/infrastructure/ocr_client/dto.go | 4 +- internal/services/ocr/service.go | 26 +- internal/transport/telegram/bot.go | 168 +++++++++++-- ocr-service/Dockerfile | 5 +- ocr-service/{ => app}/main.py | 119 +++++---- ocr-service/app/schemas/models.py | 15 ++ ocr-service/app/services/auth.py | 68 +++++ ocr-service/app/services/excel.py | 46 ++++ ocr-service/app/services/llm.py | 237 ++++++++++++++++++ .../{yandex_ocr.py => app/services/ocr.py} | 73 ++---- .../{qr_manager.py => app/services/qr.py} | 51 +++- ocr-service/imgproc.py | 97 ------- ocr-service/llm_parser.py | 150 ----------- ocr-service/ocr.py | 38 --- ocr-service/parser.py | 132 ---------- ocr-service/python_project_dump.py | 37 +-- ocr-service/requirements.txt | 4 +- ocr-service/scripts/collect_data_raw.py | 67 +++++ ocr-service/scripts/test_parsing_quality.py | 62 +++++ rmser-view/.gitignore | 3 +- 22 files changed, 1011 insertions(+), 577 deletions(-) create mode 100644 go_backend_dump.py rename ocr-service/{ => app}/main.py (53%) create mode 100644 ocr-service/app/schemas/models.py create mode 100644 ocr-service/app/services/auth.py create mode 100644 ocr-service/app/services/excel.py create mode 100644 ocr-service/app/services/llm.py rename ocr-service/{yandex_ocr.py => app/services/ocr.py} (65%) rename ocr-service/{qr_manager.py => app/services/qr.py} (65%) delete mode 100644 ocr-service/imgproc.py delete mode 100644 ocr-service/llm_parser.py delete mode 100644 ocr-service/ocr.py delete mode 100644 ocr-service/parser.py create mode 100644 ocr-service/scripts/collect_data_raw.py create mode 100644 ocr-service/scripts/test_parsing_quality.py diff --git a/cmd/main.go b/cmd/main.go index 7753ac0..45fea63 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -113,7 +113,7 @@ func main() { // 8. Telegram Bot (Передаем syncService) if cfg.Telegram.Token != "" { - bot, err := tgBot.NewBot(cfg.Telegram, ocrService, syncService, billingService, accountRepo, rmsFactory, cryptoManager) + bot, err := tgBot.NewBot(cfg.Telegram, ocrService, syncService, billingService, accountRepo, rmsFactory, cryptoManager, cfg.App.MaintenanceMode, cfg.App.DevIDs) if err != nil { logger.Log.Fatal("Ошибка создания Telegram бота", zap.Error(err)) } diff --git a/go_backend_dump.py b/go_backend_dump.py new file mode 100644 index 0000000..95a1cac --- /dev/null +++ b/go_backend_dump.py @@ -0,0 +1,184 @@ +# -*- coding: utf-8 -*- +# Этот файл сгенерирован автоматически. +# Содержит дерево проекта и файлы (.go, .json, .mod, .md) в экранированном виде. + +project_tree = ''' +. +├── .env +├── Dockerfile +├── README.md +├── cmd +│ └── main.go +├── config +│ └── config.go +├── config.yaml +├── docker-compose.yml +├── go.mod +├── go.sum +├── internal +│ ├── domain +│ │ ├── account +│ │ │ └── entity.go +│ │ ├── billing +│ │ │ └── entity.go +│ │ ├── catalog +│ │ │ ├── entity.go +│ │ │ └── store.go +│ │ ├── drafts +│ │ │ └── entity.go +│ │ ├── interfaces.go +│ │ ├── invoices +│ │ │ └── entity.go +│ │ ├── ocr +│ │ │ └── entity.go +│ │ ├── operations +│ │ │ └── entity.go +│ │ ├── photos +│ │ │ └── entity.go +│ │ ├── recipes +│ │ │ └── entity.go +│ │ ├── recommendations +│ │ │ └── entity.go +│ │ └── suppliers +│ │ └── entity.go +│ ├── infrastructure +│ │ ├── db +│ │ │ └── postgres.go +│ │ ├── ocr_client +│ │ │ ├── client.go +│ │ │ └── dto.go +│ │ ├── redis +│ │ │ └── client.go +│ │ ├── repository +│ │ │ ├── account +│ │ │ │ └── postgres.go +│ │ │ ├── billing +│ │ │ │ └── postgres.go +│ │ │ ├── catalog +│ │ │ │ └── postgres.go +│ │ │ ├── drafts +│ │ │ │ └── postgres.go +│ │ │ ├── invoices +│ │ │ │ └── postgres.go +│ │ │ ├── ocr +│ │ │ │ └── postgres.go +│ │ │ ├── operations +│ │ │ │ └── postgres.go +│ │ │ ├── photos +│ │ │ │ └── postgres.go +│ │ │ ├── recipes +│ │ │ │ └── postgres.go +│ │ │ ├── recommendations +│ │ │ │ └── postgres.go +│ │ │ └── suppliers +│ │ │ └── postgres.go +│ │ ├── rms +│ │ │ ├── client.go +│ │ │ ├── dto.go +│ │ │ └── factory.go +│ │ └── yookassa +│ │ ├── client.go +│ │ └── dto.go +│ ├── services +│ │ ├── billing +│ │ │ └── service.go +│ │ ├── drafts +│ │ │ └── service.go +│ │ ├── invoices +│ │ │ └── service.go +│ │ ├── ocr +│ │ │ └── service.go +│ │ ├── photos +│ │ │ └── service.go +│ │ ├── recommend +│ │ │ └── service.go +│ │ └── sync +│ │ └── service.go +│ └── transport +│ ├── http +│ │ ├── handlers +│ │ │ ├── billing.go +│ │ │ ├── drafts.go +│ │ │ ├── invoices.go +│ │ │ ├── ocr.go +│ │ │ ├── photos.go +│ │ │ ├── recommendations.go +│ │ │ └── settings.go +│ │ └── middleware +│ │ └── auth.go +│ └── telegram +│ ├── bot.go +│ └── fsm.go +└── pkg + ├── crypto + │ └── crypto.go + └── logger + └── logger.go +''' + +project_files = { + "README.md": "**RMSer** — это SaaS-платформа, которая избавляет бухгалтеров и менеджеров ресторанов от рутинного ввода накладных. С помощью оптического распознавания (OCR) и алгоритмов сопоставления данных, сервис сокращает время приемки товара в несколько раз.\n\n## ✨ Ключевые преимущества\n\n### 🧠 Умный матчинг и самообучение\nИнтерфейс сопоставления позволяет один раз связать текст из чека поставщика («Томат черри 250г имп») с вашей позицией в iiko («Томаты Черри»). Система сохраняет эту связь и при следующих загрузках подставляет нужный товар автоматически.\n\n### ⚙️ Тонкая настройка под объект\n* **Склад по умолчанию:** Автоматическая подстановка нужного склада для каждой новой накладной.\n* **Корневая группа поиска:** Ограничьте область поиска товаров (например, только категория «Продукты»). Это исключает попадание в накладные услуг, инвентаря или блюд.\n* **Авто-проведение:** Возможность настроить автоматическое создание накладной в статусе «Проведено» (Processed).\n* **Добавление фасовок:** Для каждого товара из iiko можно добавить фасовку и текст из чека будет сопоставлен с ней.\n\n### 👥 Управление командой в приложении\nПолноценный интерфейс управления пользователями внутри Telegram Mini App:\n* **Owner:** Полный доступ и управление подпиской.\n* **Admin:** Настройка интеграции и сопоставление товаров.\n* **Operator:** Режим «только фото» — идеально для линейного персонала на приемке.\n* **Инвайт-система:** Быстрое добавление сотрудников через ссылку.\n\n## 🛠 Стек технологий\n\n* **Language:** Go 1.25\n* **Framework:** Gin Gonic (REST API)\n* **Database:** PostgreSQL 16 (GORM)\n* **Cache:** Redis\n* **Bot Engine:** Telebot v3\n* **Containerization:** Docker & Docker Compose\n* **Security:** AES-256 (GCM) для шифрования учетных данных RMS.\n\n## 💳 Биллинг и Бонусы\nСервис работает по модели Pay-as-you-go (пакеты накладных) или по подписке. \n**Welcome Bonus:** При подключении нового сервера система автоматически начисляет **10 накладных на 30 дней** для бесплатного тестирования.", + "cmd/main.go": "package main\n\nimport (\n\t\"log\"\n\t\"os\"\n\t\"time\"\n\n\t\"github.com/gin-contrib/cors\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/google/uuid\"\n\t\"go.uber.org/zap\"\n\n\t\"rmser/config\"\n\t\"rmser/internal/infrastructure/db\"\n\t\"rmser/internal/infrastructure/ocr_client\"\n\t\"rmser/internal/infrastructure/yookassa\"\n\n\t\"rmser/internal/transport/http/middleware\"\n\ttgBot \"rmser/internal/transport/telegram\"\n\n\t// Repositories\n\taccountPkg \"rmser/internal/infrastructure/repository/account\"\n\tbillingPkg \"rmser/internal/infrastructure/repository/billing\"\n\tcatalogPkg \"rmser/internal/infrastructure/repository/catalog\"\n\tdraftsPkg \"rmser/internal/infrastructure/repository/drafts\"\n\tinvoicesPkg \"rmser/internal/infrastructure/repository/invoices\"\n\tocrRepoPkg \"rmser/internal/infrastructure/repository/ocr\"\n\topsRepoPkg \"rmser/internal/infrastructure/repository/operations\"\n\tphotosPkg \"rmser/internal/infrastructure/repository/photos\"\n\trecipesPkg \"rmser/internal/infrastructure/repository/recipes\"\n\trecRepoPkg \"rmser/internal/infrastructure/repository/recommendations\"\n\tsuppliersPkg \"rmser/internal/infrastructure/repository/suppliers\"\n\n\t\"rmser/internal/infrastructure/rms\"\n\n\t// Services\n\tbillingServicePkg \"rmser/internal/services/billing\"\n\tdraftsServicePkg \"rmser/internal/services/drafts\"\n\tinvoicesServicePkg \"rmser/internal/services/invoices\"\n\tocrServicePkg \"rmser/internal/services/ocr\"\n\tphotosServicePkg \"rmser/internal/services/photos\"\n\trecServicePkg \"rmser/internal/services/recommend\"\n\t\"rmser/internal/services/sync\"\n\n\t// Handlers\n\t\"rmser/internal/transport/http/handlers\"\n\n\t\"rmser/pkg/crypto\"\n\t\"rmser/pkg/logger\"\n)\n\nfunc main() {\n\t// 1. Config\n\tcfg, err := config.LoadConfig(\".\")\n\tif err != nil {\n\t\tlog.Fatalf(\"Ошибка загрузки конфига: %v\", err)\n\t}\n\n\t// 2. Logger\n\tlogger.Init(cfg.App.Mode)\n\tdefer logger.Log.Sync()\n\n\tif err := os.MkdirAll(cfg.App.StoragePath, 0755); err != nil {\n\t\tlogger.Log.Fatal(\"Не удалось создать директорию для загрузок\", zap.Error(err), zap.String(\"path\", cfg.App.StoragePath))\n\t}\n\tlogger.Log.Info(\"Запуск приложения rmser\", zap.String(\"mode\", cfg.App.Mode))\n\n\t// 3. Crypto & DB\n\tif cfg.Security.SecretKey == \"\" {\n\t\tlogger.Log.Fatal(\"Security.SecretKey не задан в конфиге!\")\n\t}\n\tcryptoManager := crypto.NewCryptoManager(cfg.Security.SecretKey)\n\tdatabase := db.NewPostgresDB(cfg.DB.DSN)\n\n\t// 4. Repositories\n\taccountRepo := accountPkg.NewRepository(database)\n\tbillingRepo := billingPkg.NewRepository(database)\n\tcatalogRepo := catalogPkg.NewRepository(database)\n\trecipesRepo := recipesPkg.NewRepository(database)\n\tinvoicesRepo := invoicesPkg.NewRepository(database)\n\topsRepo := opsRepoPkg.NewRepository(database)\n\trecRepo := recRepoPkg.NewRepository(database)\n\tocrRepo := ocrRepoPkg.NewRepository(database)\n\tphotosRepo := photosPkg.NewRepository(database)\n\tdraftsRepo := draftsPkg.NewRepository(database)\n\tsupplierRepo := suppliersPkg.NewRepository(database)\n\n\t// 5. RMS Factory\n\trmsFactory := rms.NewFactory(accountRepo, cryptoManager)\n\n\t// 6. Services\n\tpyClient := ocr_client.NewClient(cfg.OCR.ServiceURL)\n\tykClient := yookassa.NewClient(cfg.YooKassa.ShopID, cfg.YooKassa.SecretKey)\n\tbillingService := billingServicePkg.NewService(billingRepo, accountRepo, ykClient)\n\n\tsyncService := sync.NewService(rmsFactory, accountRepo, catalogRepo, recipesRepo, invoicesRepo, opsRepo, supplierRepo)\n\trecService := recServicePkg.NewService(recRepo)\n\tocrService := ocrServicePkg.NewService(ocrRepo, catalogRepo, draftsRepo, accountRepo, photosRepo, pyClient, cfg.App.StoragePath)\n\t// Устанавливаем DevIDs для OCR сервиса\n\tocrService.SetDevIDs(cfg.App.DevIDs)\n\tdraftsService := draftsServicePkg.NewService(draftsRepo, ocrRepo, catalogRepo, accountRepo, supplierRepo, photosRepo, invoicesRepo, rmsFactory, billingService)\n\tinvoicesService := invoicesServicePkg.NewService(invoicesRepo, draftsRepo, supplierRepo, rmsFactory)\n\tphotosService := photosServicePkg.NewService(photosRepo, draftsRepo, accountRepo)\n\n\t// 7. Handlers\n\tdraftsHandler := handlers.NewDraftsHandler(draftsService)\n\tbillingHandler := handlers.NewBillingHandler(billingService)\n\tocrHandler := handlers.NewOCRHandler(ocrService)\n\tphotosHandler := handlers.NewPhotosHandler(photosService)\n\trecommendHandler := handlers.NewRecommendationsHandler(recService)\n\tsettingsHandler := handlers.NewSettingsHandler(accountRepo, catalogRepo)\n\tinvoicesHandler := handlers.NewInvoiceHandler(invoicesService, syncService)\n\n\t// 8. Telegram Bot (Передаем syncService)\n\tif cfg.Telegram.Token != \"\" {\n\t\tbot, err := tgBot.NewBot(cfg.Telegram, ocrService, syncService, billingService, accountRepo, rmsFactory, cryptoManager, cfg.App.MaintenanceMode, cfg.App.DevIDs)\n\t\tif err != nil {\n\t\t\tlogger.Log.Fatal(\"Ошибка создания Telegram бота\", zap.Error(err))\n\t\t}\n\t\tbillingService.SetNotifier(bot)\n\t\tsettingsHandler.SetNotifier(bot)\n\t\t// Устанавливаем нотификатор для OCR сервиса\n\t\tocrService.SetNotifier(bot)\n\t\tgo bot.Start()\n\t\tdefer bot.Stop()\n\t}\n\n\t// 9. HTTP Server\n\tif cfg.App.Mode == \"release\" {\n\t\tgin.SetMode(gin.ReleaseMode)\n\t}\n\tr := gin.Default()\n\n\tr.POST(\"/api/webhooks/yookassa\", billingHandler.YooKassaWebhook)\n\n\tcorsConfig := cors.DefaultConfig()\n\tcorsConfig.AllowAllOrigins = true\n\tcorsConfig.AllowMethods = []string{\"GET\", \"POST\", \"PUT\", \"PATCH\", \"DELETE\", \"OPTIONS\"}\n\tcorsConfig.AllowHeaders = []string{\"Origin\", \"Content-Length\", \"Content-Type\", \"Authorization\", \"X-Telegram-User-ID\"}\n\tr.Use(cors.New(corsConfig))\n\n\t// --- STATIC FILES SERVING ---\n\t// Раздаем папку uploads по урлу /api/uploads\n\tr.Static(\"/api/uploads\", cfg.App.StoragePath)\n\n\tapi := r.Group(\"/api\")\n\n\tapi.Use(middleware.AuthMiddleware(accountRepo, cfg.Telegram.Token, cfg.App.MaintenanceMode, cfg.App.DevIDs))\n\t{\n\t\t// Drafts & Invoices\n\t\tapi.GET(\"/drafts\", draftsHandler.GetDrafts)\n\t\tapi.GET(\"/drafts/:id\", draftsHandler.GetDraft)\n\t\tapi.DELETE(\"/drafts/:id\", draftsHandler.DeleteDraft)\n\t\t// Items CRUD\n\t\tapi.POST(\"/drafts/:id/items\", draftsHandler.AddDraftItem)\n\t\tapi.DELETE(\"/drafts/:id/items/:itemId\", draftsHandler.DeleteDraftItem)\n\t\tapi.PATCH(\"/drafts/:id/items/:itemId\", draftsHandler.UpdateItem)\n\t\tapi.POST(\"/drafts/:id/commit\", draftsHandler.CommitDraft)\n\t\tapi.POST(\"/drafts/container\", draftsHandler.AddContainer)\n\n\t\t// Settings\n\t\tapi.GET(\"/settings\", settingsHandler.GetSettings)\n\t\tapi.POST(\"/settings\", settingsHandler.UpdateSettings)\n\t\t// Photos Storage\n\t\tapi.GET(\"/photos\", photosHandler.GetPhotos)\n\t\tapi.DELETE(\"/photos/:id\", photosHandler.DeletePhoto)\n\t\t// User Management\n\t\tapi.GET(\"/settings/users\", settingsHandler.GetServerUsers)\n\t\tapi.PATCH(\"/settings/users/:userId\", settingsHandler.UpdateUserRole)\n\t\tapi.DELETE(\"/settings/users/:userId\", settingsHandler.RemoveUser)\n\n\t\t// Dictionaries\n\t\tapi.GET(\"/dictionaries\", draftsHandler.GetDictionaries)\n\t\tapi.GET(\"/dictionaries/groups\", settingsHandler.GetGroupsTree)\n\t\tapi.GET(\"/dictionaries/stores\", draftsHandler.GetStores)\n\n\t\t// Recommendations\n\t\tapi.GET(\"/recommendations\", recommendHandler.GetRecommendations)\n\n\t\t// OCR & Matching\n\t\tapi.GET(\"/ocr/catalog\", ocrHandler.GetCatalog)\n\t\tapi.GET(\"/ocr/matches\", ocrHandler.GetMatches)\n\t\tapi.POST(\"/ocr/match\", ocrHandler.SaveMatch)\n\t\tapi.DELETE(\"/ocr/match\", ocrHandler.DeleteMatch)\n\t\tapi.GET(\"/ocr/unmatched\", ocrHandler.GetUnmatched)\n\t\tapi.DELETE(\"/ocr/unmatched\", ocrHandler.DiscardUnmatched)\n\t\tapi.GET(\"/ocr/search\", ocrHandler.SearchProducts)\n\n\t\t// Invoices\n\t\tapi.GET(\"/invoices/:id\", invoicesHandler.GetInvoice)\n\t\tapi.POST(\"/invoices/sync\", invoicesHandler.SyncInvoices)\n\n\t\t// Manual Sync Trigger\n\t\tapi.POST(\"/sync/all\", func(c *gin.Context) {\n\t\t\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\t\t\tforce := c.Query(\"force\") == \"true\"\n\t\t\t// Запускаем в горутине, чтобы не держать соединение\n\t\t\tgo func() {\n\t\t\t\tif err := syncService.SyncAllData(userID, force); err != nil {\n\t\t\t\t\tlogger.Log.Error(\"Manual sync failed\", zap.Error(err))\n\t\t\t\t}\n\t\t\t}()\n\t\t\tc.JSON(200, gin.H{\"status\": \"sync_started\", \"message\": \"Синхронизация запущена в фоне\"})\n\t\t})\n\t}\n\n\tr.GET(\"/health\", func(c *gin.Context) {\n\t\tc.JSON(200, gin.H{\"status\": \"ok\", \"time\": time.Now().Format(time.RFC3339)})\n\t})\n\n\tlogger.Log.Info(\"Сервер запускается\", zap.String(\"port\", cfg.App.Port))\n\tif err := r.Run(\":\" + cfg.App.Port); err != nil {\n\t\tlogger.Log.Fatal(\"Ошибка запуска сервера\", zap.Error(err))\n\t}\n}\n", + "config.yaml": "app:\n port: \"8080\"\n mode: \"debug\" # debug выводит цветные логи\n drop_tables: false\n storage_path: \"./uploads\"\n public_url: \"https://rmser.serty.top\"\n maintenance_mode: true\n dev_ids: [665599275] # Укажите здесь ваш ID и ID тестировщиков\n\ndb:\n dsn: \"host=postgres user=rmser password=mhrcadmin994525 dbname=rmser_db port=5432 sslmode=disable TimeZone=Europe/Moscow\"\n\nredis:\n addr: \"localhost:6379\"\n password: \"\"\n db: 0\n\nrms:\n base_url: \"https://rest-mesto-vstrechi.iiko.it\" # Например http://95.12.34.56:8080\n login: \"MH\"\n password: \"MhLevfqkexit632597\" # Пароль в открытом виде (приложение само хеширует)\n\nocr:\n service_url: \"http://ocr-service:5005\"\n\nsecurity:\n secret_key: \"mhrcadmin994525\"\n\ntelegram:\n token: \"7858843765:AAE5HBQHbef4fGLoMDV91bhHZQFcnsBDcv4\"\n admin_ids: [665599275]\n web_app_url: \"https://rmser.serty.top\"\n\nyookassa:\n shop_id: \"1234397\"\n secret_key: \"live_bRlT9tJRi1hvP7_-C6xjdmzNHpaz9rIs9G0gzv6OPA0\"", + "config/config.go": "package config\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/spf13/viper\"\n)\n\ntype Config struct {\n\tApp AppConfig\n\tDB DBConfig\n\tRedis RedisConfig\n\tRMS RMSConfig\n\tOCR OCRConfig\n\tTelegram TelegramConfig\n\tSecurity SecurityConfig\n\tYooKassa YooKassaConfig `mapstructure:\"yookassa\"`\n}\n\ntype AppConfig struct {\n\tPort string `mapstructure:\"port\"`\n\tMode string `mapstructure:\"mode\"` // debug/release\n\tDropTables bool `mapstructure:\"drop_tables\"`\n\tStoragePath string `mapstructure:\"storage_path\"`\n\tPublicURL string `mapstructure:\"public_url\"`\n\n\tMaintenanceMode bool `mapstructure:\"maintenance_mode\"`\n DevIDs []int64 `mapstructure:\"dev_ids\"` // Whitelist для режима разработки\n}\n\ntype DBConfig struct {\n\tDSN string `mapstructure:\"dsn\"`\n}\n\ntype RedisConfig struct {\n\tAddr string `mapstructure:\"addr\"`\n\tPassword string `mapstructure:\"password\"`\n\tDB int `mapstructure:\"db\"`\n}\ntype RMSConfig struct {\n\tBaseURL string `mapstructure:\"base_url\"`\n\tLogin string `mapstructure:\"login\"`\n\tPassword string `mapstructure:\"password\"` // Исходный пароль, хеширование будет в клиенте\n}\n\ntype OCRConfig struct {\n\tServiceURL string `mapstructure:\"service_url\"`\n}\n\ntype TelegramConfig struct {\n\tToken string `mapstructure:\"token\"`\n\tAdminIDs []int64 `mapstructure:\"admin_ids\"`\n\tWebAppURL string `mapstructure:\"web_app_url\"`\n}\n\ntype SecurityConfig struct {\n\tSecretKey string `mapstructure:\"secret_key\"` // 32 bytes for AES-256\n}\n\ntype YooKassaConfig struct {\n\tShopID string `mapstructure:\"shop_id\"`\n\tSecretKey string `mapstructure:\"secret_key\"`\n}\n\n// LoadConfig загружает конфигурацию из файла и переменных окружения\nfunc LoadConfig(path string) (*Config, error) {\n\tviper.AddConfigPath(path)\n\tviper.SetConfigName(\"config\")\n\tviper.SetConfigType(\"yaml\")\n\n\tviper.AutomaticEnv()\n\tviper.SetEnvKeyReplacer(strings.NewReplacer(\".\", \"_\"))\n\n\tif err := viper.ReadInConfig(); err != nil {\n\t\treturn nil, fmt.Errorf(\"ошибка чтения конфига: %w\", err)\n\t}\n\n\tvar cfg Config\n\tif err := viper.Unmarshal(&cfg); err != nil {\n\t\treturn nil, fmt.Errorf(\"ошибка анмаршалинга конфига: %w\", err)\n\t}\n\n\treturn &cfg, nil\n}\n", + "go.mod": "module rmser\n\ngo 1.25.5\n\nrequire (\n\tgithub.com/gin-contrib/cors v1.7.6\n\tgithub.com/gin-gonic/gin v1.11.0\n\tgithub.com/google/uuid v1.6.0\n\tgithub.com/jackc/pgx/v5 v5.6.0\n\tgithub.com/redis/go-redis/v9 v9.17.1\n\tgithub.com/shopspring/decimal v1.4.0\n\tgithub.com/spf13/viper v1.21.0\n\tgo.uber.org/zap v1.27.1\n\tgopkg.in/telebot.v3 v3.3.8\n\tgorm.io/driver/postgres v1.6.0\n\tgorm.io/gorm v1.31.1\n)\n\nrequire (\n\tgithub.com/bytedance/sonic v1.14.0 // indirect\n\tgithub.com/bytedance/sonic/loader v0.3.0 // indirect\n\tgithub.com/cespare/xxhash/v2 v2.3.0 // indirect\n\tgithub.com/cloudwego/base64x v0.1.6 // indirect\n\tgithub.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect\n\tgithub.com/fsnotify/fsnotify v1.9.0 // indirect\n\tgithub.com/gabriel-vasile/mimetype v1.4.9 // indirect\n\tgithub.com/gin-contrib/sse v1.1.0 // indirect\n\tgithub.com/go-playground/locales v0.14.1 // indirect\n\tgithub.com/go-playground/universal-translator v0.18.1 // indirect\n\tgithub.com/go-playground/validator/v10 v10.27.0 // indirect\n\tgithub.com/go-viper/mapstructure/v2 v2.4.0 // indirect\n\tgithub.com/goccy/go-json v0.10.5 // indirect\n\tgithub.com/goccy/go-yaml v1.18.0 // indirect\n\tgithub.com/jackc/pgpassfile v1.0.0 // indirect\n\tgithub.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect\n\tgithub.com/jackc/puddle/v2 v2.2.2 // indirect\n\tgithub.com/jinzhu/inflection v1.0.0 // indirect\n\tgithub.com/jinzhu/now v1.1.5 // indirect\n\tgithub.com/json-iterator/go v1.1.12 // indirect\n\tgithub.com/klauspost/cpuid/v2 v2.3.0 // indirect\n\tgithub.com/leodido/go-urn v1.4.0 // indirect\n\tgithub.com/mattn/go-isatty v0.0.20 // indirect\n\tgithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect\n\tgithub.com/modern-go/reflect2 v1.0.2 // indirect\n\tgithub.com/pelletier/go-toml/v2 v2.2.4 // indirect\n\tgithub.com/quic-go/qpack v0.5.1 // indirect\n\tgithub.com/quic-go/quic-go v0.54.0 // indirect\n\tgithub.com/sagikazarmark/locafero v0.11.0 // indirect\n\tgithub.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect\n\tgithub.com/spf13/afero v1.15.0 // indirect\n\tgithub.com/spf13/cast v1.10.0 // indirect\n\tgithub.com/spf13/pflag v1.0.10 // indirect\n\tgithub.com/subosito/gotenv v1.6.0 // indirect\n\tgithub.com/twitchyliquid64/golang-asm v0.15.1 // indirect\n\tgithub.com/ugorji/go/codec v1.3.0 // indirect\n\tgo.uber.org/mock v0.5.0 // indirect\n\tgo.uber.org/multierr v1.10.0 // indirect\n\tgo.yaml.in/yaml/v3 v3.0.4 // indirect\n\tgolang.org/x/arch v0.20.0 // indirect\n\tgolang.org/x/crypto v0.40.0 // indirect\n\tgolang.org/x/mod v0.26.0 // indirect\n\tgolang.org/x/net v0.42.0 // indirect\n\tgolang.org/x/sync v0.16.0 // indirect\n\tgolang.org/x/sys v0.35.0 // indirect\n\tgolang.org/x/text v0.28.0 // indirect\n\tgolang.org/x/tools v0.35.0 // indirect\n\tgoogle.golang.org/protobuf v1.36.9 // indirect\n)\n", + "internal/domain/account/entity.go": "package account\n\nimport (\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n)\n\n// Роли пользователей\ntype Role string\n\nconst (\n\tRoleOwner Role = \"OWNER\" // Создатель: Полный доступ + удаление сервера\n\tRoleAdmin Role = \"ADMIN\" // Администратор: Редактирование, настройки, приглашение\n\tRoleOperator Role = \"OPERATOR\" // Оператор: Только загрузка фото\n)\n\n// User - Пользователь системы (Telegram аккаунт)\ntype User struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;default:gen_random_uuid()\" json:\"id\"`\n\tTelegramID int64 `gorm:\"uniqueIndex;not null\" json:\"telegram_id\"`\n\tUsername string `gorm:\"type:varchar(100)\" json:\"username\"`\n\tFirstName string `gorm:\"type:varchar(100)\" json:\"first_name\"`\n\tLastName string `gorm:\"type:varchar(100)\" json:\"last_name\"`\n\tPhotoURL string `gorm:\"type:text\" json:\"photo_url\"`\n\n\tIsSystemAdmin bool `gorm:\"default:false\" json:\"is_system_admin\"`\n\n\tCreatedAt time.Time `json:\"created_at\"`\n\tUpdatedAt time.Time `json:\"updated_at\"`\n}\n\n// ServerUser - Связь пользователя с сервером (здесь храним личные креды)\ntype ServerUser struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;default:gen_random_uuid()\"`\n\tServerID uuid.UUID `gorm:\"type:uuid;not null;index:idx_user_server,unique\"`\n\tUserID uuid.UUID `gorm:\"type:uuid;not null;index:idx_user_server,unique\"`\n\n\tRole Role `gorm:\"type:varchar(20);default:'OPERATOR'\"`\n\tIsActive bool `gorm:\"default:false\"` // Выбран ли этот сервер сейчас\n\n\t// Персональные данные для подключения (могут быть null у операторов)\n\tLogin string `gorm:\"type:varchar(100)\"`\n\tEncryptedPassword string `gorm:\"type:text\"`\n\n\tServer RMSServer `gorm:\"foreignKey:ServerID\"`\n\tUser User `gorm:\"foreignKey:UserID\"`\n\n\tCreatedAt time.Time `json:\"created_at\"`\n\tUpdatedAt time.Time `json:\"updated_at\"`\n}\n\n// RMSServer - Инстанс сервера iiko\ntype RMSServer struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;default:gen_random_uuid()\" json:\"id\"`\n\n\t// Уникальный URL (очищенный), определяет инстанс\n\tBaseURL string `gorm:\"type:varchar(255);not null;uniqueIndex\" json:\"base_url\"`\n\n\tName string `gorm:\"type:varchar(100);not null\" json:\"name\"`\n\tMaxUsers int `gorm:\"default:5\" json:\"max_users\"` // Лимит пользователей\n\n\t// Глобальные настройки сервера (общие для всех)\n\tDefaultStoreID *uuid.UUID `gorm:\"type:uuid\" json:\"default_store_id\"`\n\tRootGroupGUID *uuid.UUID `gorm:\"type:uuid\" json:\"root_group_guid\"`\n\tAutoProcess bool `gorm:\"default:false\" json:\"auto_process\"`\n\n\t// Billing\n\tBalance int `gorm:\"default:0\" json:\"balance\"`\n\tPaidUntil *time.Time `json:\"paid_until\"`\n\n\t// Stats\n\tInvoiceCount int `gorm:\"default:0\" json:\"invoice_count\"`\n\n\tCreatedAt time.Time `json:\"created_at\"`\n\tUpdatedAt time.Time `json:\"updated_at\"`\n}\n\n// Repository интерфейс\ntype Repository interface {\n\t// Users\n\tGetOrCreateUser(telegramID int64, username, first, last string) (*User, error)\n\tGetUserByTelegramID(telegramID int64) (*User, error)\n\tGetUserByID(id uuid.UUID) (*User, error)\n\n\t// ConnectServer - Основной метод подключения.\n\tConnectServer(userID uuid.UUID, url, login, encryptedPass, name string) (*RMSServer, error)\n\tGetServerByURL(url string) (*RMSServer, error)\n\tGetServerByID(id uuid.UUID) (*RMSServer, error)\n\n\tSaveServerSettings(server *RMSServer) error\n\n\t// SetActiveServer переключает активность в таблице ServerUser\n\tSetActiveServer(userID, serverID uuid.UUID) error\n\n\t// GetActiveServer ищет сервер, где у UserID стоит флаг IsActive=true\n\tGetActiveServer(userID uuid.UUID) (*RMSServer, error)\n\n\t// GetActiveConnectionCredentials возвращает актуальные логин/пароль для текущего юзера (личные или общие)\n\tGetActiveConnectionCredentials(userID uuid.UUID) (url, login, passHash string, err error)\n\n\t// GetAllAvailableServers возвращает все серверы, доступные пользователю (в любом статусе)\n\tGetAllAvailableServers(userID uuid.UUID) ([]RMSServer, error)\n\tDeleteServer(serverID uuid.UUID) error\n\n\t// GetUserRole возвращает роль пользователя на сервере (или ошибку доступа)\n\tGetUserRole(userID, serverID uuid.UUID) (Role, error)\n\tSetUserRole(serverID, targetUserID uuid.UUID, newRole Role) error\n\tGetServerUsers(serverID uuid.UUID) ([]ServerUser, error)\n\n\t// Invite System\n\tAddUserToServer(serverID, userID uuid.UUID, role Role) error\n\tRemoveUserFromServer(serverID, userID uuid.UUID) error\n\n\t// Billing & Stats\n\tIncrementInvoiceCount(serverID uuid.UUID) error\n\tUpdateBalance(serverID uuid.UUID, amountChange int, newPaidUntil *time.Time) error\n\tDecrementBalance(serverID uuid.UUID) error\n\n\t// Super Admin Functions\n\tGetAllServersSystemWide() ([]RMSServer, error)\n\tTransferOwnership(serverID, newOwnerID uuid.UUID) error\n\n\t// GetConnectionByID получает связь ServerUser по её ID (нужно для админки, чтобы сократить callback_data)\n\tGetConnectionByID(id uuid.UUID) (*ServerUser, error)\n}\n", + "internal/domain/billing/entity.go": "// internal/domain/billing/entity.go\n\npackage billing\n\nimport (\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n)\n\ntype TariffType string\n\nconst (\n\tTariffPack TariffType = \"PACK\" // Пакет накладных\n\tTariffSubscription TariffType = \"SUBSCRIPTION\" // Подписка (безлимит на время)\n)\n\ntype OrderStatus string\n\nconst (\n\tStatusPending OrderStatus = \"PENDING\"\n\tStatusPaid OrderStatus = \"PAID\"\n\tStatusCanceled OrderStatus = \"CANCELED\"\n)\n\n// Tariff - Описание услуги\ntype Tariff struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tType TariffType `json:\"type\"`\n\tPrice float64 `json:\"price\"`\n\tInvoicesCount int `json:\"invoices_count\"` // Для PACK - сколько штук, для SUBSCRIPTION - 1000 (лимит)\n\tDurationDays int `json:\"duration_days\"`\n}\n\n// Order - Заказ на покупку тарифа\ntype Order struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;default:gen_random_uuid()\" json:\"id\"`\n\tUserID uuid.UUID `gorm:\"type:uuid;not null;index\" json:\"user_id\"` // Кто платит\n\tTargetServerID uuid.UUID `gorm:\"type:uuid;not null;index\" json:\"target_server_id\"` // Кому начисляем\n\tTariffID string `gorm:\"type:varchar(50);not null\" json:\"tariff_id\"`\n\tAmount float64 `gorm:\"type:numeric(19,4);not null\" json:\"amount\"`\n\tStatus OrderStatus `gorm:\"type:varchar(20);default:'PENDING'\" json:\"status\"`\n\n\tPaymentID string `gorm:\"type:varchar(100)\" json:\"payment_id\"` // ID транзакции в ЮКассе\n\n\tCreatedAt time.Time `json:\"created_at\"`\n\tUpdatedAt time.Time `json:\"updated_at\"`\n}\n\n// Repository для работы с заказами\ntype Repository interface {\n\tCreateOrder(order *Order) error\n\tGetOrder(id uuid.UUID) (*Order, error)\n\tUpdateOrderStatus(id uuid.UUID, status OrderStatus, paymentID string) error\n\tGetActiveOrdersByUser(userID uuid.UUID) ([]Order, error)\n}\n", + "internal/domain/catalog/entity.go": "package catalog\n\nimport (\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/shopspring/decimal\"\n)\n\n// MeasureUnit - Единица измерения (kg, l, pcs)\ntype MeasureUnit struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;\" json:\"id\"`\n\tRMSServerID uuid.UUID `gorm:\"type:uuid;not null;index\" json:\"-\"`\n\tName string `gorm:\"type:varchar(50);not null\" json:\"name\"`\n\tCode string `gorm:\"type:varchar(50)\" json:\"code\"`\n}\n\n// ProductContainer - Фасовка (упаковка) товара\ntype ProductContainer struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;\" json:\"id\"`\n\tRMSServerID uuid.UUID `gorm:\"type:uuid;not null;index\" json:\"-\"`\n\tProductID uuid.UUID `gorm:\"type:uuid;index;not null\" json:\"product_id\"`\n\tName string `gorm:\"type:varchar(100);not null\" json:\"name\"`\n\tCount decimal.Decimal `gorm:\"type:numeric(19,4);not null\" json:\"count\"` // Коэфф. пересчета\n}\n\n// Product - Номенклатура\ntype Product struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;\" json:\"id\"`\n\tRMSServerID uuid.UUID `gorm:\"type:uuid;not null;index\" json:\"-\"`\n\tParentID *uuid.UUID `gorm:\"type:uuid;index\" json:\"parent_id\"`\n\tName string `gorm:\"type:varchar(255);not null\" json:\"name\"`\n\tType string `gorm:\"type:varchar(50);index\" json:\"type\"` // GOODS, DISH, PREPARED\n\tNum string `gorm:\"type:varchar(50)\" json:\"num\"`\n\tCode string `gorm:\"type:varchar(50)\" json:\"code\"`\n\tUnitWeight decimal.Decimal `gorm:\"type:numeric(19,4)\" json:\"unit_weight\"`\n\tUnitCapacity decimal.Decimal `gorm:\"type:numeric(19,4)\" json:\"unit_capacity\"`\n\n\t// Связь с единицей измерения\n\tMainUnitID *uuid.UUID `gorm:\"type:uuid;index\" json:\"main_unit_id\"`\n\tMainUnit *MeasureUnit `gorm:\"foreignKey:MainUnitID\" json:\"main_unit,omitempty\"`\n\n\t// Фасовки\n\tContainers []ProductContainer `gorm:\"foreignKey:ProductID;constraint:OnDelete:CASCADE\" json:\"containers,omitempty\"`\n\n\tIsDeleted bool `gorm:\"default:false\" json:\"is_deleted\"`\n\n\tParent *Product `gorm:\"foreignKey:ParentID\" json:\"-\"`\n\tChildren []*Product `gorm:\"foreignKey:ParentID\" json:\"-\"`\n\n\tCreatedAt time.Time `json:\"created_at\"`\n\tUpdatedAt time.Time `json:\"updated_at\"`\n}\n\n// Repository интерфейс для каталога\ntype Repository interface {\n\tSaveMeasureUnits(units []MeasureUnit) error\n\tSaveProducts(products []Product) error\n\tSaveContainer(container ProductContainer) error\n\n\tSearch(serverID uuid.UUID, query string, rootGroupID *uuid.UUID) ([]Product, error)\n\tGetActiveGoods(serverID uuid.UUID, rootGroupID *uuid.UUID) ([]Product, error)\n\n\tGetGroups(serverID uuid.UUID) ([]Product, error)\n\n\tSaveStores(stores []Store) error\n\tGetActiveStores(serverID uuid.UUID) ([]Store, error)\n\n\tCountGoods(serverID uuid.UUID) (int64, error)\n\tCountStores(serverID uuid.UUID) (int64, error)\n}\n", + "internal/domain/catalog/store.go": "package catalog\n\nimport (\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n)\n\n// Store - Склад\ntype Store struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;\" json:\"id\"`\n\tRMSServerID uuid.UUID `gorm:\"type:uuid;not null;index\" json:\"-\"`\n\n\tName string `gorm:\"type:varchar(255);not null\" json:\"name\"`\n\tParentCorporateID uuid.UUID `gorm:\"type:uuid;index\" json:\"parent_corporate_id\"`\n\tIsDeleted bool `gorm:\"default:false\" json:\"is_deleted\"`\n\n\tCreatedAt time.Time `json:\"created_at\"`\n\tUpdatedAt time.Time `json:\"updated_at\"`\n}\n", + "internal/domain/drafts/entity.go": "package drafts\n\nimport (\n\t\"time\"\n\n\t\"rmser/internal/domain/catalog\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/shopspring/decimal\"\n)\n\nconst (\n\tStatusProcessing = \"PROCESSING\"\n\tStatusReadyToVerify = \"READY_TO_VERIFY\"\n\tStatusCompleted = \"COMPLETED\"\n\tStatusError = \"ERROR\"\n\tStatusCanceled = \"CANCELED\"\n\tStatusDeleted = \"DELETED\"\n)\n\ntype EditedField string\n\nconst (\n\tFieldQuantity EditedField = \"quantity\"\n\tFieldPrice EditedField = \"price\"\n\tFieldSum EditedField = \"sum\"\n)\n\ntype DraftInvoice struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;default:gen_random_uuid()\" json:\"id\"`\n\n\t// Привязка к аккаунту и серверу\n\tUserID uuid.UUID `gorm:\"type:uuid;not null;index\" json:\"user_id\"` // Кто загрузил (автор)\n\tRMSServerID uuid.UUID `gorm:\"type:uuid;not null;index\" json:\"rms_server_id\"` // К какому серверу относится\n\n\tSenderPhotoURL string `gorm:\"type:text\" json:\"photo_url\"`\n\tStatus string `gorm:\"type:varchar(50);default:'PROCESSING'\" json:\"status\"`\n\n\tDocumentNumber string `gorm:\"type:varchar(100)\" json:\"document_number\"`\n\t// Входящий номер документа\n\tIncomingDocumentNumber string `gorm:\"type:varchar(100)\" json:\"incoming_document_number\"`\n\tDateIncoming *time.Time `json:\"date_incoming\"`\n\tSupplierID *uuid.UUID `gorm:\"type:uuid\" json:\"supplier_id\"`\n\n\tStoreID *uuid.UUID `gorm:\"type:uuid\" json:\"store_id\"`\n\tStore *catalog.Store `gorm:\"foreignKey:StoreID\" json:\"store,omitempty\"`\n\n\tComment string `gorm:\"type:text\" json:\"comment\"`\n\tRMSInvoiceID *uuid.UUID `gorm:\"type:uuid\" json:\"rms_invoice_id\"`\n\n\tItems []DraftInvoiceItem `gorm:\"foreignKey:DraftID;constraint:OnDelete:CASCADE\" json:\"items\"`\n\n\tCreatedAt time.Time `json:\"created_at\"`\n\tUpdatedAt time.Time `json:\"updated_at\"`\n}\n\ntype DraftInvoiceItem struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;default:gen_random_uuid()\" json:\"id\"`\n\tDraftID uuid.UUID `gorm:\"type:uuid;not null;index\" json:\"draft_id\"`\n\n\tRawName string `gorm:\"type:varchar(255);not null\" json:\"raw_name\"`\n\tRawAmount decimal.Decimal `gorm:\"type:numeric(19,4)\" json:\"raw_amount\"`\n\tRawPrice decimal.Decimal `gorm:\"type:numeric(19,4)\" json:\"raw_price\"`\n\n\tProductID *uuid.UUID `gorm:\"type:uuid;index\" json:\"product_id\"`\n\tProduct *catalog.Product `gorm:\"foreignKey:ProductID\" json:\"product,omitempty\"`\n\tContainerID *uuid.UUID `gorm:\"type:uuid;index\" json:\"container_id\"`\n\tContainer *catalog.ProductContainer `gorm:\"foreignKey:ContainerID\" json:\"container,omitempty\"`\n\n\tQuantity decimal.Decimal `gorm:\"type:numeric(19,4);default:0\" json:\"quantity\"`\n\tPrice decimal.Decimal `gorm:\"type:numeric(19,4);default:0\" json:\"price\"`\n\tSum decimal.Decimal `gorm:\"type:numeric(19,4);default:0\" json:\"sum\"`\n\n\t// Два последних отредактированных поля (для автопересчёта)\n\tLastEditedField1 EditedField `gorm:\"column:last_edited_field1;type:varchar(20);default:'quantity'\" json:\"last_edited_field_1\"`\n\tLastEditedField2 EditedField `gorm:\"column:last_edited_field2;type:varchar(20);default:'price'\" json:\"last_edited_field_2\"`\n\n\tIsMatched bool `gorm:\"default:false\" json:\"is_matched\"`\n}\n\ntype Repository interface {\n\tCreate(draft *DraftInvoice) error\n\tGetByID(id uuid.UUID) (*DraftInvoice, error)\n\tGetByRMSInvoiceID(rmsInvoiceID uuid.UUID) (*DraftInvoice, error)\n\tGetItemByID(itemID uuid.UUID) (*DraftInvoiceItem, error)\n\tCreateItems(items []DraftInvoiceItem) error\n\tUpdate(draft *DraftInvoice) error\n\tUpdateItem(itemID uuid.UUID, updates map[string]interface{}) error\n\tCreateItem(item *DraftInvoiceItem) error\n\tDeleteItem(itemID uuid.UUID) error\n\tDelete(id uuid.UUID) error\n\n\t// GetActive возвращает активные черновики для СЕРВЕРА (а не юзера)\n\tGetActive(serverID uuid.UUID) ([]DraftInvoice, error)\n\n\t// GetRMSInvoiceIDToPhotoURLMap возвращает мапу rms_invoice_id -> sender_photo_url для сервера, где rms_invoice_id не NULL\n\tGetRMSInvoiceIDToPhotoURLMap(serverID uuid.UUID) (map[uuid.UUID]string, error)\n}\n", + "internal/domain/interfaces.go": "package domain\n\nimport (\n\t\"rmser/internal/domain/catalog\"\n\t\"rmser/internal/domain/invoices\"\n\t\"rmser/internal/domain/recipes\"\n\t\"time\"\n)\n\ntype Repository interface {\n\t// Catalog\n\tSaveProducts(products []catalog.Product) error\n\n\t// Recipes\n\tSaveRecipes(recipes []recipes.Recipe) error\n\n\t// Invoices\n\tGetLastInvoiceDate() (*time.Time, error)\n\tSaveInvoices(invoices []invoices.Invoice) error\n}\n", + "internal/domain/invoices/entity.go": "package invoices\n\nimport (\n\t\"time\"\n\n\t\"rmser/internal/domain/catalog\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/shopspring/decimal\"\n)\n\n// Invoice - Приходная накладная\ntype Invoice struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;\"`\n\tRMSServerID uuid.UUID `gorm:\"type:uuid;not null;index\"`\n\tDocumentNumber string `gorm:\"type:varchar(100);index\"`\n\tIncomingDocumentNumber string `gorm:\"type:varchar(100)\"`\n\tDateIncoming time.Time `gorm:\"index\"`\n\tSupplierID uuid.UUID `gorm:\"type:uuid;index\"`\n\tDefaultStoreID uuid.UUID `gorm:\"type:uuid;index\"`\n\tStatus string `gorm:\"type:varchar(50)\"`\n\tComment string `gorm:\"type:text\"`\n\n\tItems []InvoiceItem `gorm:\"foreignKey:InvoiceID;constraint:OnDelete:CASCADE\"`\n\n\tCreatedAt time.Time\n\tUpdatedAt time.Time\n}\n\n// InvoiceItem - Позиция накладной\ntype InvoiceItem struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;default:gen_random_uuid()\"`\n\tInvoiceID uuid.UUID `gorm:\"type:uuid;not null;index\"`\n\tProductID uuid.UUID `gorm:\"type:uuid;not null\"`\n\tContainerID *uuid.UUID `gorm:\"type:uuid\"`\n\tAmount decimal.Decimal `gorm:\"type:numeric(19,4);not null\"`\n\tPrice decimal.Decimal `gorm:\"type:numeric(19,4);not null\"`\n\tSum decimal.Decimal `gorm:\"type:numeric(19,4);not null\"`\n\tVatSum decimal.Decimal `gorm:\"type:numeric(19,4)\"`\n\n\tProduct catalog.Product `gorm:\"foreignKey:ProductID\"`\n}\n\ntype Repository interface {\n\tGetByID(id uuid.UUID) (*Invoice, error)\n\tGetLastInvoiceDate(serverID uuid.UUID) (*time.Time, error)\n\tGetByPeriod(serverID uuid.UUID, from, to time.Time) ([]Invoice, error)\n\tSaveInvoices(invoices []Invoice) error\n\tCountRecent(serverID uuid.UUID, days int) (int64, error)\n}\n", + "internal/domain/ocr/entity.go": "package ocr\n\nimport (\n\t\"time\"\n\n\t\"rmser/internal/domain/catalog\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/shopspring/decimal\"\n)\n\n// ProductMatch\ntype ProductMatch struct {\n\t// RawName больше не PrimaryKey, так как в разных серверах один текст может значить разное\n\t// Делаем составной ключ или суррогатный ID.\n\t// Для простоты GORM: ID - uuid, а уникальность через индекс (RawName + ServerID)\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;default:gen_random_uuid()\"`\n\tRMSServerID uuid.UUID `gorm:\"type:uuid;not null;index:idx_raw_server,unique\"`\n\n\tRawName string `gorm:\"type:varchar(255);not null;index:idx_raw_server,unique\" json:\"raw_name\"`\n\tProductID uuid.UUID `gorm:\"type:uuid;not null;index\" json:\"product_id\"`\n\tProduct catalog.Product `gorm:\"foreignKey:ProductID\" json:\"product\"`\n\n\tQuantity decimal.Decimal `gorm:\"type:numeric(19,4);default:1\" json:\"quantity\"`\n\tContainerID *uuid.UUID `gorm:\"type:uuid;index\" json:\"container_id\"`\n\tContainer *catalog.ProductContainer `gorm:\"foreignKey:ContainerID\" json:\"container,omitempty\"`\n\n\tUpdatedAt time.Time `json:\"updated_at\"`\n\tCreatedAt time.Time `json:\"created_at\"`\n}\n\n// UnmatchedItem тоже стоит делить, чтобы подсказывать пользователю только его нераспознанные,\n// хотя глобальная база unmatched может быть полезна для аналитики.\n// Сделаем раздельной для чистоты SaaS.\ntype UnmatchedItem struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;default:gen_random_uuid()\"`\n\tRMSServerID uuid.UUID `gorm:\"type:uuid;not null;index:idx_unm_raw_server,unique\"`\n\n\tRawName string `gorm:\"type:varchar(255);not null;index:idx_unm_raw_server,unique\" json:\"raw_name\"`\n\tCount int `gorm:\"default:1\" json:\"count\"`\n\tLastSeen time.Time `json:\"last_seen\"`\n}\n\ntype Repository interface {\n\tSaveMatch(serverID uuid.UUID, rawName string, productID uuid.UUID, quantity decimal.Decimal, containerID *uuid.UUID) error\n\tDeleteMatch(serverID uuid.UUID, rawName string) error\n\tFindMatch(serverID uuid.UUID, rawName string) (*ProductMatch, error)\n\tGetAllMatches(serverID uuid.UUID) ([]ProductMatch, error)\n\n\tUpsertUnmatched(serverID uuid.UUID, rawName string) error\n\tGetTopUnmatched(serverID uuid.UUID, limit int) ([]UnmatchedItem, error)\n\tDeleteUnmatched(serverID uuid.UUID, rawName string) error\n}\n", + "internal/domain/operations/entity.go": "package operations\n\nimport (\n\t\"time\"\n\n\t\"rmser/internal/domain/catalog\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/shopspring/decimal\"\n)\n\ntype OperationType string\n\nconst (\n\tOpTypePurchase OperationType = \"PURCHASE\" // Закупка (Приход)\n\tOpTypeUsage OperationType = \"USAGE\" // Расход (Реализация + Списание)\n\tOpTypeUnknown OperationType = \"UNKNOWN\" // Прочее (Инвентаризация, Перемещения - игнорируем пока)\n)\n\n// StoreOperation - запись из складского отчета\ntype StoreOperation struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;default:gen_random_uuid()\"`\n\tRMSServerID uuid.UUID `gorm:\"type:uuid;not null;index\"`\n\tProductID uuid.UUID `gorm:\"type:uuid;not null;index\"`\n\n\t// Наш внутренний, \"очищенный\" тип операции\n\tOpType OperationType `gorm:\"type:varchar(50);index\"`\n\n\t// Raw данные из iiko для отладки и детализации\n\tDocumentType string `gorm:\"type:varchar(100);index\"` // INCOMING_INVOICE, etc.\n\tTransactionType string `gorm:\"type:varchar(100)\"` // INVOICE, WRITEOFF, etc.\n\tDocumentNumber string `gorm:\"type:varchar(100)\"`\n\n\tAmount decimal.Decimal `gorm:\"type:numeric(19,4)\"`\n\tSum decimal.Decimal `gorm:\"type:numeric(19,4)\"`\n\tCost decimal.Decimal `gorm:\"type:numeric(19,4)\"`\n\n\t// Период синхронизации (для перезаписи данных)\n\tPeriodFrom time.Time `gorm:\"index\"`\n\tPeriodTo time.Time `gorm:\"index\"`\n\n\tProduct catalog.Product `gorm:\"foreignKey:ProductID\"`\n\n\tCreatedAt time.Time\n}\n\ntype Repository interface {\n\tSaveOperations(ops []StoreOperation, serverID uuid.UUID, opType OperationType, dateFrom, dateTo time.Time) error\n}\n", + "internal/domain/photos/entity.go": "package photos\n\nimport (\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n)\n\ntype PhotoStatus string\n\nconst (\n\tPhotoStatusOrphan PhotoStatus = \"ORPHAN\" // Нет связанного черновика\n\tPhotoStatusHasDraft PhotoStatus = \"HAS_DRAFT\" // Есть черновик\n\tPhotoStatusHasInvoice PhotoStatus = \"HAS_INVOICE\" // Есть накладная в iiko\n)\n\n// ReceiptPhoto - фото чека\ntype ReceiptPhoto struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;default:gen_random_uuid()\" json:\"id\"`\n\tRMSServerID uuid.UUID `gorm:\"type:uuid;not null;index\" json:\"rms_server_id\"`\n\tUploadedBy uuid.UUID `gorm:\"type:uuid;not null\" json:\"uploaded_by\"` // User ID\n\n\tFilePath string `gorm:\"type:varchar(500);not null\" json:\"file_path\"`\n\tFileURL string `gorm:\"type:varchar(500);not null\" json:\"file_url\"` // Public URL\n\tFileName string `gorm:\"type:varchar(255)\" json:\"file_name\"`\n\tFileSize int64 `json:\"file_size\"`\n\n\t// Связи (указатели, так как могут быть null)\n\tDraftID *uuid.UUID `gorm:\"type:uuid;index\" json:\"draft_id\"`\n\tInvoiceID *uuid.UUID `gorm:\"type:uuid;index\" json:\"invoice_id\"` // RMS Invoice ID (когда накладная создана)\n\n\t// Метаданные OCR (чтобы можно было пересоздать черновик без повторного запроса к нейросети)\n\tOCRSource string `gorm:\"type:varchar(50)\" json:\"ocr_source\"` // qr_api, yandex, etc.\n\tOCRRawText string `gorm:\"type:text\" json:\"ocr_raw_text\"`\n\n\tCreatedAt time.Time `json:\"created_at\"`\n\tUpdatedAt time.Time `json:\"updated_at\"`\n}\n\n// Repository interface\ntype Repository interface {\n\tCreate(photo *ReceiptPhoto) error\n\tGetByID(id uuid.UUID) (*ReceiptPhoto, error)\n\t// GetByServerID возвращает фото с пагинацией\n\tGetByServerID(serverID uuid.UUID, page, limit int) ([]ReceiptPhoto, int64, error)\n\n\tUpdateDraftLink(photoID uuid.UUID, draftID *uuid.UUID) error\n\tUpdateInvoiceLink(photoID uuid.UUID, invoiceID *uuid.UUID) error\n\n\t// ClearDraftLinkByDraftID очищает ссылку на черновик (когда черновик удаляется)\n\tClearDraftLinkByDraftID(draftID uuid.UUID) error\n\n\tDelete(id uuid.UUID) error\n}\n", + "internal/domain/recipes/entity.go": "package recipes\n\nimport (\n\t\"time\"\n\n\t\"rmser/internal/domain/catalog\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/shopspring/decimal\"\n)\n\n// Recipe - Технологическая карта\ntype Recipe struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;\"`\n\tRMSServerID uuid.UUID `gorm:\"type:uuid;not null;index\"`\n\tProductID uuid.UUID `gorm:\"type:uuid;not null;index\"`\n\tDateFrom time.Time `gorm:\"index\"`\n\tDateTo *time.Time\n\n\tProduct catalog.Product `gorm:\"foreignKey:ProductID\"`\n\tItems []RecipeItem `gorm:\"foreignKey:RecipeID;constraint:OnDelete:CASCADE\"`\n}\n\n// RecipeItem - Ингредиент\ntype RecipeItem struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;default:gen_random_uuid()\"`\n\tRMSServerID uuid.UUID `gorm:\"type:uuid;not null;index\"`\n\tRecipeID uuid.UUID `gorm:\"type:uuid;not null;index\"`\n\tProductID uuid.UUID `gorm:\"type:uuid;not null;index\"`\n\tAmountIn decimal.Decimal `gorm:\"type:numeric(19,4);not null\"`\n\tAmountOut decimal.Decimal `gorm:\"type:numeric(19,4);not null\"`\n\n\tProduct catalog.Product `gorm:\"foreignKey:ProductID\"`\n}\n\ntype Repository interface {\n\tSaveRecipes(recipes []Recipe) error\n}\n", + "internal/domain/recommendations/entity.go": "package recommendations\n\nimport (\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n)\n\n// Типы проблем\nconst (\n\tTypeUnused = \"UNUSED_IN_RECIPES\" // Товар не используется в техкартах\n\tTypeNoIncoming = \"NO_INCOMING\" // Ингредиент (GOODS) в техкарте, но нет приходов\n\tTypeStale = \"STALE_GOODS\" // Есть приходы, но нет продаж\n\tTypeDishInRecipe = \"DISH_IN_RECIPE\" // Блюдо (DISH) в составе другого блюда\n\tTypePurchasedButUnused = \"PURCHASED_BUT_UNUSED\" // Активно закупается, но нет в техкартах\n\tTypeUsageNoIncoming = \"USAGE_NO_INCOMING\" // Есть расходы, но нет приходов\n)\n\n// Recommendation - Результат анализа\ntype Recommendation struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;default:gen_random_uuid()\"`\n\tType string `gorm:\"type:varchar(50);index\"`\n\tProductID uuid.UUID `gorm:\"type:uuid;index\"`\n\tProductName string `gorm:\"type:varchar(255)\"`\n\tReason string `gorm:\"type:text\"`\n\n\tCreatedAt time.Time\n}\n\n// Repository отвечает за аналитические выборки и хранение результатов\ntype Repository interface {\n\t// Методы анализа (возвращают список структур, но не пишут в БД)\n\tFindUnusedGoods() ([]Recommendation, error)\n\tFindNoIncomingIngredients(days int) ([]Recommendation, error)\n\tFindStaleGoods(days int) ([]Recommendation, error)\n\tFindDishesInRecipes() ([]Recommendation, error)\n\tFindPurchasedButUnused(days int) ([]Recommendation, error)\n\tFindUsageWithoutPurchase(days int) ([]Recommendation, error)\n\n\t// Методы \"Кэша\" в БД\n\tSaveAll(items []Recommendation) error // Удаляет старые и пишет новые\n\tGetAll() ([]Recommendation, error)\n}\n", + "internal/domain/suppliers/entity.go": "package suppliers\n\nimport (\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n)\n\n// Supplier - Поставщик (Контрагент)\ntype Supplier struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;\" json:\"id\"`\n\tName string `gorm:\"type:varchar(255);not null\" json:\"name\"`\n\tCode string `gorm:\"type:varchar(50)\" json:\"code\"`\n\tINN string `gorm:\"type:varchar(20)\" json:\"inn\"` // taxpayerIdNumber\n\n\t// Привязка к конкретному серверу iiko (Multi-tenant)\n\tRMSServerID uuid.UUID `gorm:\"type:uuid;not null;index\" json:\"-\"`\n\n\tIsDeleted bool `gorm:\"default:false\" json:\"is_deleted\"`\n\n\tUpdatedAt time.Time `json:\"updated_at\"`\n}\n\ntype Repository interface {\n\tSaveBatch(suppliers []Supplier) error\n\tGetByID(id uuid.UUID) (*Supplier, error)\n\t// GetRankedByUsage возвращает поставщиков, отсортированных по частоте использования в накладных за N дней\n\tGetRankedByUsage(serverID uuid.UUID, daysLookBack int) ([]Supplier, error)\n\tCount(serverID uuid.UUID) (int64, error)\n}\n", + "internal/infrastructure/db/postgres.go": "package db\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"regexp\"\n\t\"rmser/internal/domain/account\"\n\t\"rmser/internal/domain/billing\"\n\t\"rmser/internal/domain/catalog\"\n\t\"rmser/internal/domain/drafts\"\n\t\"rmser/internal/domain/invoices\"\n\t\"rmser/internal/domain/ocr\"\n\t\"rmser/internal/domain/operations\"\n\t\"rmser/internal/domain/photos\"\n\t\"rmser/internal/domain/recipes\"\n\t\"rmser/internal/domain/recommendations\"\n\t\"rmser/internal/domain/suppliers\"\n\t\"time\"\n\n\t_ \"github.com/jackc/pgx/v5/stdlib\"\n\t\"gorm.io/driver/postgres\"\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/logger\"\n)\n\nfunc NewPostgresDB(dsn string) *gorm.DB {\n\t// 1. Проверка и создание БД перед основным подключением\n\tensureDBExists(dsn)\n\n\t// 2. Настройка логгера GORM\n\tnewLogger := logger.New(\n\t\tlog.New(os.Stdout, \"\\r\\n\", log.LstdFlags),\n\t\tlogger.Config{\n\t\t\tSlowThreshold: time.Second,\n\t\t\tLogLevel: logger.Warn,\n\t\t\tIgnoreRecordNotFoundError: true,\n\t\t\tColorful: true,\n\t\t},\n\t)\n\n\t// 3. Основное подключение\n\tdb, err := gorm.Open(postgres.Open(dsn), &gorm.Config{\n\t\tLogger: newLogger,\n\t})\n\tif err != nil {\n\t\tpanic(fmt.Sprintf(\"не удалось подключиться к БД: %v\", err))\n\t}\n\n\t// 4. Автомиграция\n\terr = db.AutoMigrate(\n\t\t&account.User{},\n\t\t&account.RMSServer{},\n\t\t&account.ServerUser{},\n\t\t&billing.Order{},\n\t\t&catalog.Product{},\n\t\t&catalog.MeasureUnit{},\n\t\t&catalog.ProductContainer{},\n\t\t&catalog.Store{},\n\t\t&suppliers.Supplier{},\n\t\t&recipes.Recipe{},\n\t\t&recipes.RecipeItem{},\n\t\t&invoices.Invoice{},\n\t\t&invoices.InvoiceItem{},\n\t\t&drafts.DraftInvoice{},\n\t\t&drafts.DraftInvoiceItem{},\n\t\t&operations.StoreOperation{},\n\t\t&recommendations.Recommendation{},\n\t\t&ocr.ProductMatch{},\n\t\t&ocr.UnmatchedItem{},\n\t\t&photos.ReceiptPhoto{},\n\t)\n\tif err != nil {\n\t\tpanic(fmt.Sprintf(\"ошибка миграции БД: %v\", err))\n\t}\n\n\treturn db\n}\n\n// ensureDBExists подключается к системной БД 'postgres' и создает целевую, если её нет\nfunc ensureDBExists(fullDSN string) {\n\t// Регулярка для извлечения имени базы из DSN (ищем dbname=... )\n\tre := regexp.MustCompile(`dbname=([^\\s]+)`)\n\tmatches := re.FindStringSubmatch(fullDSN)\n\n\tif len(matches) < 2 {\n\t\t// Если не нашли dbname, возможно формат URL (postgres://...),\n\t\t// пропускаем авто-создание, полагаемся на ошибку драйвера\n\t\treturn\n\t}\n\n\ttargetDB := matches[1]\n\n\t// Заменяем целевую БД на системную 'postgres' для подключения\n\tmaintenanceDSN := re.ReplaceAllString(fullDSN, \"dbname=postgres\")\n\n\t// Используем стандартный sql драйвер через pgx (который под капотом у gorm/postgres)\n\t// Важно: нам не нужен GORM здесь, нужен чистый SQL для CREATE DATABASE\n\tdb, err := sql.Open(\"pgx\", maintenanceDSN)\n\tif err != nil {\n\t\t// Если не вышло подключиться к postgres, просто выходим,\n\t\t// основная ошибка вылетит при попытке gorm.Open\n\t\tlog.Printf(\"[WARN] Не удалось подключиться к системной БД для проверки: %v\", err)\n\t\treturn\n\t}\n\tdefer db.Close()\n\n\t// Проверяем существование базы\n\tvar exists bool\n\tcheckSQL := fmt.Sprintf(\"SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = '%s')\", targetDB)\n\terr = db.QueryRow(checkSQL).Scan(&exists)\n\tif err != nil {\n\t\tlog.Printf(\"[WARN] Ошибка проверки существования БД: %v\", err)\n\t\treturn\n\t}\n\n\tif !exists {\n\t\tlog.Printf(\"[INFO] База данных '%s' не найдена. Создаю...\", targetDB)\n\t\t// CREATE DATABASE не может быть выполнен в транзакции, поэтому Exec\n\t\t_, err = db.Exec(fmt.Sprintf(\"CREATE DATABASE \\\"%s\\\"\", targetDB))\n\t\tif err != nil {\n\t\t\tpanic(fmt.Sprintf(\"не удалось создать базу данных %s: %v\", targetDB, err))\n\t\t}\n\t\tlog.Printf(\"[INFO] База данных '%s' успешно создана\", targetDB)\n\t}\n}\n", + "internal/infrastructure/ocr_client/client.go": "package ocr_client\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"mime/multipart\"\n\t\"net/http\"\n\t\"net/textproto\"\n\t\"time\"\n)\n\ntype Client struct {\n\tpythonServiceURL string\n\thttpClient *http.Client\n}\n\nfunc NewClient(pythonServiceURL string) *Client {\n\treturn &Client{\n\t\tpythonServiceURL: pythonServiceURL,\n\t\thttpClient: &http.Client{\n\t\t\t// OCR может быть долгим, ставим таймаут побольше (например, 30 сек)\n\t\t\tTimeout: 30 * time.Second,\n\t\t},\n\t}\n}\n\n// ProcessImage отправляет изображение в Python и возвращает сырые данные\nfunc (c *Client) ProcessImage(ctx context.Context, imageData []byte, filename string) (*RecognitionResult, error) {\n\t// 1. Создаем буфер для multipart формы\n\tbody := &bytes.Buffer{}\n\twriter := multipart.NewWriter(body)\n\n\t// Создаем заголовок части вручную, чтобы прописать Content-Type: image/jpeg\n\th := make(textproto.MIMEHeader)\n\th.Set(\"Content-Disposition\", fmt.Sprintf(`form-data; name=\"image\"; filename=\"%s\"`, filename))\n\th.Set(\"Content-Type\", \"image/jpeg\") // Явно указываем, что это картинка\n\n\tpart, err := writer.CreatePart(h)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"create part error: %w\", err)\n\t}\n\n\t// Записываем байты картинки\n\tif _, err := io.Copy(part, bytes.NewReader(imageData)); err != nil {\n\t\treturn nil, fmt.Errorf(\"copy file error: %w\", err)\n\t}\n\n\t// Закрываем writer, чтобы записать boundary\n\tif err := writer.Close(); err != nil {\n\t\treturn nil, fmt.Errorf(\"writer close error: %w\", err)\n\t}\n\n\t// 2. Создаем запрос\n\treq, err := http.NewRequestWithContext(ctx, \"POST\", c.pythonServiceURL+\"/recognize\", body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"create request error: %w\", err)\n\t}\n\n\t// Важно: Content-Type с boundary\n\treq.Header.Set(\"Content-Type\", writer.FormDataContentType())\n\n\t// 3. Отправляем\n\tresp, err := c.httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"ocr service request failed: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tbodyBytes, _ := io.ReadAll(resp.Body)\n\t\treturn nil, fmt.Errorf(\"ocr service error (code %d): %s\", resp.StatusCode, string(bodyBytes))\n\t}\n\n\t// 4. Парсим ответ\n\tvar result RecognitionResult\n\tif err := json.NewDecoder(resp.Body).Decode(&result); err != nil {\n\t\treturn nil, fmt.Errorf(\"json decode error: %w\", err)\n\t}\n\n\treturn &result, nil\n}\n", + "internal/infrastructure/ocr_client/dto.go": "package ocr_client\n\n// RecognitionResult - ответ от Python сервиса\ntype RecognitionResult struct {\n\tItems []RecognizedItem `json:\"items\"`\n}\n\ntype RecognizedItem struct {\n\tRawName string `json:\"raw_name\"` // Текст названия из чека\n\tAmount float64 `json:\"amount\"` // Кол-во\n\tPrice float64 `json:\"price\"` // Цена\n\tSum float64 `json:\"sum\"` // Сумма\n}\n", + "internal/infrastructure/redis/client.go": "package redis\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/redis/go-redis/v9\"\n)\n\ntype Client struct {\n\trdb *redis.Client\n}\n\nfunc NewClient(addr, password string, dbIndex int) (*Client, error) {\n\trdb := redis.NewClient(&redis.Options{\n\t\tAddr: addr,\n\t\tPassword: password,\n\t\tDB: dbIndex,\n\t})\n\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\tif err := rdb.Ping(ctx).Err(); err != nil {\n\t\treturn nil, fmt.Errorf(\"ошибка подключения к Redis: %w\", err)\n\t}\n\n\treturn &Client{rdb: rdb}, nil\n}\n\n// Set сохраняет значение (структуру) в JSON\nfunc (c *Client) Set(ctx context.Context, key string, value any, ttl time.Duration) error {\n\tbytes, err := json.Marshal(value)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"json marshal error: %w\", err)\n\t}\n\treturn c.rdb.Set(ctx, key, bytes, ttl).Err()\n}\n\n// Get загружает значение в переданный указатель dest\nfunc (c *Client) Get(ctx context.Context, key string, dest any) error {\n\tval, err := c.rdb.Get(ctx, key).Result()\n\tif err != nil {\n\t\tif err == redis.Nil {\n\t\t\treturn nil // Ключ не найден, не считаем ошибкой\n\t\t}\n\t\treturn err\n\t}\n\treturn json.Unmarshal([]byte(val), dest)\n}\n", + "internal/infrastructure/repository/account/postgres.go": "package account\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"rmser/internal/domain/account\"\n\n\t\"github.com/google/uuid\"\n\t\"gorm.io/gorm\"\n)\n\ntype pgRepository struct {\n\tdb *gorm.DB\n}\n\nfunc NewRepository(db *gorm.DB) account.Repository {\n\treturn &pgRepository{db: db}\n}\n\n// GetOrCreateUser находит пользователя или создает нового\nfunc (r *pgRepository) GetOrCreateUser(telegramID int64, username, first, last string) (*account.User, error) {\n\tvar user account.User\n\terr := r.db.Where(\"telegram_id = ?\", telegramID).First(&user).Error\n\n\tif err != nil {\n\t\tif err == gorm.ErrRecordNotFound {\n\t\t\tnewUser := account.User{\n\t\t\t\tTelegramID: telegramID,\n\t\t\t\tUsername: username,\n\t\t\t\tFirstName: first,\n\t\t\t\tLastName: last,\n\t\t\t}\n\t\t\tif err := r.db.Create(&newUser).Error; err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\treturn &newUser, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\n\t// Обновляем инфо\n\tif user.Username != username || user.FirstName != first || user.LastName != last {\n\t\tuser.Username = username\n\t\tuser.FirstName = first\n\t\tuser.LastName = last\n\t\tr.db.Save(&user)\n\t}\n\n\treturn &user, nil\n}\n\nfunc (r *pgRepository) GetUserByTelegramID(telegramID int64) (*account.User, error) {\n\tvar user account.User\n\terr := r.db.Where(\"telegram_id = ?\", telegramID).First(&user).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &user, nil\n}\n\nfunc (r *pgRepository) GetUserByID(id uuid.UUID) (*account.User, error) {\n\tvar user account.User\n\terr := r.db.Where(\"id = ?\", id).First(&user).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &user, nil\n}\n\n// ConnectServer - Основная точка входа для добавления сервера\nfunc (r *pgRepository) ConnectServer(userID uuid.UUID, rawURL, login, encryptedPass, name string) (*account.RMSServer, error) {\n\tcleanURL := strings.TrimRight(strings.ToLower(strings.TrimSpace(rawURL)), \"/\")\n\n\tvar server account.RMSServer\n\tvar created bool\n\n\terr := r.db.Transaction(func(tx *gorm.DB) error {\n\t\terr := tx.Where(\"base_url = ?\", cleanURL).First(&server).Error\n\t\tif err != nil && err != gorm.ErrRecordNotFound {\n\t\t\treturn err\n\t\t}\n\n\t\tif err == gorm.ErrRecordNotFound {\n\t\t\t// --- СЦЕНАРИЙ 1: НОВЫЙ СЕРВЕР (Приветственный бонус) ---\n\t\t\ttrialDays := 30\n\t\t\twelcomeBalance := 10\n\t\t\tpaidUntil := time.Now().AddDate(0, 0, trialDays)\n\n\t\t\tserver = account.RMSServer{\n\t\t\t\tBaseURL: cleanURL,\n\t\t\t\tName: name,\n\t\t\t\tMaxUsers: 5,\n\t\t\t\tBalance: welcomeBalance,\n\t\t\t\tPaidUntil: &paidUntil,\n\t\t\t}\n\t\t\tif err := tx.Create(&server).Error; err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tcreated = true\n\t\t} else {\n\t\t\t// --- СЦЕНАРИЙ 2: СУЩЕСТВУЮЩИЙ СЕРВЕР ---\n\t\t\tvar userCount int64\n\t\t\ttx.Model(&account.ServerUser{}).Where(\"server_id = ?\", server.ID).Count(&userCount)\n\t\t\tif userCount >= int64(server.MaxUsers) {\n\t\t\t\tvar exists int64\n\t\t\t\ttx.Model(&account.ServerUser{}).Where(\"server_id = ? AND user_id = ?\", server.ID, userID).Count(&exists)\n\t\t\t\tif exists == 0 {\n\t\t\t\t\treturn fmt.Errorf(\"достигнут лимит пользователей на сервере (%d)\", server.MaxUsers)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\ttargetRole := account.RoleOperator\n\t\tif created {\n\t\t\ttargetRole = account.RoleOwner\n\t\t}\n\n\t\tif err := tx.Model(&account.ServerUser{}).Where(\"user_id = ?\", userID).Update(\"is_active\", false).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tvar existingLink account.ServerUser\n\t\terr = tx.Where(\"server_id = ? AND user_id = ?\", server.ID, userID).First(&existingLink).Error\n\t\tif err == nil {\n\t\t\texistingLink.Login = login\n\t\t\texistingLink.EncryptedPassword = encryptedPass\n\t\t\texistingLink.IsActive = true\n\t\t\treturn tx.Save(&existingLink).Error\n\t\t}\n\n\t\tuserLink := account.ServerUser{\n\t\t\tServerID: server.ID,\n\t\t\tUserID: userID,\n\t\t\tRole: targetRole,\n\t\t\tIsActive: true,\n\t\t\tLogin: login,\n\t\t\tEncryptedPassword: encryptedPass,\n\t\t}\n\t\treturn tx.Create(&userLink).Error\n\t})\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &server, nil\n}\n\nfunc (r *pgRepository) SaveServerSettings(server *account.RMSServer) error {\n\treturn r.db.Model(server).Updates(map[string]interface{}{\n\t\t\"name\": server.Name,\n\t\t\"default_store_id\": server.DefaultStoreID,\n\t\t\"root_group_guid\": server.RootGroupGUID,\n\t\t\"auto_process\": server.AutoProcess,\n\t\t\"max_users\": server.MaxUsers,\n\t}).Error\n}\n\nfunc (r *pgRepository) SetActiveServer(userID, serverID uuid.UUID) error {\n\treturn r.db.Transaction(func(tx *gorm.DB) error {\n\t\t// Проверка доступа\n\t\tvar count int64\n\t\ttx.Model(&account.ServerUser{}).Where(\"user_id = ? AND server_id = ?\", userID, serverID).Count(&count)\n\t\tif count == 0 {\n\t\t\treturn errors.New(\"доступ к серверу запрещен\")\n\t\t}\n\n\t\tif err := tx.Model(&account.ServerUser{}).Where(\"user_id = ?\", userID).Update(\"is_active\", false).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn tx.Model(&account.ServerUser{}).Where(\"user_id = ? AND server_id = ?\", userID, serverID).Update(\"is_active\", true).Error\n\t})\n}\n\nfunc (r *pgRepository) GetActiveServer(userID uuid.UUID) (*account.RMSServer, error) {\n\tvar server account.RMSServer\n\terr := r.db.Table(\"rms_servers\").\n\t\tSelect(\"rms_servers.*\").\n\t\tJoins(\"JOIN server_users ON server_users.server_id = rms_servers.id\").\n\t\tWhere(\"server_users.user_id = ? AND server_users.is_active = ?\", userID, true).\n\t\tFirst(&server).Error\n\n\tif err != nil {\n\t\tif err == gorm.ErrRecordNotFound {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\treturn &server, nil\n}\n\n// GetActiveConnectionCredentials возвращает креды для подключения.\n// Логика:\n// 1. Берем личные креды из server_users (если активен)\n// 2. Если личных нет (пустой пароль) -> ищем креды Владельца (Owner) этого сервера\nfunc (r *pgRepository) GetActiveConnectionCredentials(userID uuid.UUID) (url, login, passHash string, err error) {\n\t// 1. Получаем связь текущего юзера с активным сервером\n\ttype Result struct {\n\t\tServerID uuid.UUID\n\t\tBaseURL string\n\t\tLogin string\n\t\tEncryptedPassword string\n\t}\n\tvar res Result\n\n\terr = r.db.Table(\"server_users\").\n\t\tSelect(\"server_users.server_id, rms_servers.base_url, server_users.login, server_users.encrypted_password\").\n\t\tJoins(\"JOIN rms_servers ON rms_servers.id = server_users.server_id\").\n\t\tWhere(\"server_users.user_id = ? AND server_users.is_active = ?\", userID, true).\n\t\tScan(&res).Error\n\n\tif err != nil {\n\t\treturn \"\", \"\", \"\", err\n\t}\n\tif res.ServerID == uuid.Nil {\n\t\treturn \"\", \"\", \"\", errors.New(\"нет активного сервера\")\n\t}\n\n\t// Если есть личные креды - возвращаем их\n\tif res.Login != \"\" && res.EncryptedPassword != \"\" {\n\t\treturn res.BaseURL, res.Login, res.EncryptedPassword, nil\n\t}\n\n\t// 2. Фоллбэк: ищем креды владельца (OWNER)\n\tvar ownerLink account.ServerUser\n\terr = r.db.Where(\"server_id = ? AND role = ?\", res.ServerID, account.RoleOwner).\n\t\tOrder(\"created_at ASC\"). // На случай коллизий, берем старейшего\n\t\tFirst(&ownerLink).Error\n\n\tif err != nil {\n\t\treturn \"\", \"\", \"\", fmt.Errorf(\"у вас нет учетных данных, а владелец сервера не найден: %w\", err)\n\t}\n\n\tif ownerLink.Login == \"\" || ownerLink.EncryptedPassword == \"\" {\n\t\treturn \"\", \"\", \"\", errors.New(\"у владельца сервера отсутствуют учетные данные\")\n\t}\n\n\treturn res.BaseURL, ownerLink.Login, ownerLink.EncryptedPassword, nil\n}\n\nfunc (r *pgRepository) GetAllAvailableServers(userID uuid.UUID) ([]account.RMSServer, error) {\n\tvar servers []account.RMSServer\n\terr := r.db.Table(\"rms_servers\").\n\t\tSelect(\"rms_servers.*\").\n\t\tJoins(\"JOIN server_users ON server_users.server_id = rms_servers.id\").\n\t\tWhere(\"server_users.user_id = ?\", userID).\n\t\tFind(&servers).Error\n\treturn servers, err\n}\n\nfunc (r *pgRepository) DeleteServer(serverID uuid.UUID) error {\n\t// Полное удаление сервера и всех связей\n\treturn r.db.Transaction(func(tx *gorm.DB) error {\n\t\tif err := tx.Where(\"server_id = ?\", serverID).Delete(&account.ServerUser{}).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := tx.Delete(&account.RMSServer{}, serverID).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n}\n\n// --- Управление правами ---\n\nfunc (r *pgRepository) GetUserRole(userID, serverID uuid.UUID) (account.Role, error) {\n\tvar link account.ServerUser\n\terr := r.db.Select(\"role\").Where(\"user_id = ? AND server_id = ?\", userID, serverID).First(&link).Error\n\tif err != nil {\n\t\tif err == gorm.ErrRecordNotFound {\n\t\t\treturn \"\", errors.New(\"access denied\")\n\t\t}\n\t\treturn \"\", err\n\t}\n\treturn link.Role, nil\n}\n\nfunc (r *pgRepository) SetUserRole(serverID, targetUserID uuid.UUID, newRole account.Role) error {\n\treturn r.db.Model(&account.ServerUser{}).\n\t\tWhere(\"server_id = ? AND user_id = ?\", serverID, targetUserID).\n\t\tUpdate(\"role\", newRole).Error\n}\n\nfunc (r *pgRepository) GetServerUsers(serverID uuid.UUID) ([]account.ServerUser, error) {\n\tvar users []account.ServerUser\n\t// Preload User для отображения имен\n\terr := r.db.Preload(\"User\").Where(\"server_id = ?\", serverID).Find(&users).Error\n\treturn users, err\n}\n\nfunc (r *pgRepository) AddUserToServer(serverID, userID uuid.UUID, role account.Role) error {\n\t// Проверка лимита перед добавлением\n\tvar server account.RMSServer\n\tif err := r.db.First(&server, serverID).Error; err != nil {\n\t\treturn err\n\t}\n\n\treturn r.db.Transaction(func(tx *gorm.DB) error {\n\t\t// 1. Сначала проверяем, существует ли пользователь на этом сервере\n\t\tvar existingLink account.ServerUser\n\t\terr := tx.Where(\"server_id = ? AND user_id = ?\", serverID, userID).First(&existingLink).Error\n\n\t\tif err == nil {\n\t\t\t// --- ПОЛЬЗОВАТЕЛЬ УЖЕ ЕСТЬ ---\n\t\t\t// Защита от понижения прав:\n\t\t\t// Если текущая роль OWNER или ADMIN, а мы пытаемся поставить OPERATOR (через инвайт),\n\t\t\t// то игнорируем смену роли, просто делаем активным.\n\t\t\tif (existingLink.Role == account.RoleOwner || existingLink.Role == account.RoleAdmin) && role == account.RoleOperator {\n\t\t\t\trole = existingLink.Role\n\t\t\t}\n\n\t\t\t// Обновляем активность и (возможно) роль\n\t\t\treturn tx.Model(&existingLink).Updates(map[string]interface{}{\n\t\t\t\t\"role\": role,\n\t\t\t\t\"is_active\": true,\n\t\t\t}).Error\n\t\t}\n\n\t\t// --- ПОЛЬЗОВАТЕЛЬ НОВЫЙ ---\n\t\t// Проверяем лимит только для новых\n\t\tvar currentCount int64\n\t\ttx.Model(&account.ServerUser{}).Where(\"server_id = ?\", serverID).Count(¤tCount)\n\t\tif currentCount >= int64(server.MaxUsers) {\n\t\t\treturn fmt.Errorf(\"лимит пользователей (%d) превышен\", server.MaxUsers)\n\t\t}\n\n\t\t// Сбрасываем активность на других серверах\n\t\tif err := tx.Model(&account.ServerUser{}).Where(\"user_id = ?\", userID).Update(\"is_active\", false).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Создаем связь\n\t\tlink := account.ServerUser{\n\t\t\tServerID: serverID,\n\t\t\tUserID: userID,\n\t\t\tRole: role,\n\t\t\tIsActive: true,\n\t\t}\n\t\treturn tx.Create(&link).Error\n\t})\n}\n\nfunc (r *pgRepository) RemoveUserFromServer(serverID, userID uuid.UUID) error {\n\treturn r.db.Where(\"server_id = ? AND user_id = ?\", serverID, userID).Delete(&account.ServerUser{}).Error\n}\n\nfunc (r *pgRepository) IncrementInvoiceCount(serverID uuid.UUID) error {\n\treturn r.db.Model(&account.RMSServer{}).\n\t\tWhere(\"id = ?\", serverID).\n\t\tUpdateColumn(\"invoice_count\", gorm.Expr(\"invoice_count + ?\", 1)).Error\n}\n\n// --- Super Admin Functions ---\n\nfunc (r *pgRepository) GetAllServersSystemWide() ([]account.RMSServer, error) {\n\tvar servers []account.RMSServer\n\t// Загружаем вместе с владельцем для отображения\n\terr := r.db.Order(\"name ASC\").Find(&servers).Error\n\treturn servers, err\n}\n\nfunc (r *pgRepository) TransferOwnership(serverID, newOwnerID uuid.UUID) error {\n\treturn r.db.Transaction(func(tx *gorm.DB) error {\n\t\t// 1. Находим текущего владельца\n\t\tvar currentOwnerLink account.ServerUser\n\t\tif err := tx.Where(\"server_id = ? AND role = ?\", serverID, account.RoleOwner).First(¤tOwnerLink).Error; err != nil {\n\t\t\treturn fmt.Errorf(\"current owner not found: %w\", err)\n\t\t}\n\n\t\t// 2. Проверяем, что новый владелец вообще есть на сервере\n\t\tvar newOwnerLink account.ServerUser\n\t\tif err := tx.Where(\"server_id = ? AND user_id = ?\", serverID, newOwnerID).First(&newOwnerLink).Error; err != nil {\n\t\t\treturn fmt.Errorf(\"target user not found on server: %w\", err)\n\t\t}\n\n\t\t// 3. Понижаем старого владельца до ADMIN\n\t\tif err := tx.Model(¤tOwnerLink).Update(\"role\", account.RoleAdmin).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// 4. Повышаем нового до OWNER\n\t\tif err := tx.Model(&newOwnerLink).Update(\"role\", account.RoleOwner).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// УДАЛЕНО: обновление server.owner_id, так как этого поля нет в модели\n\n\t\treturn nil\n\t})\n}\n\nfunc (r *pgRepository) GetConnectionByID(id uuid.UUID) (*account.ServerUser, error) {\n\tvar link account.ServerUser\n\t// Preload нужны, чтобы показать имена в админке\n\terr := r.db.Preload(\"User\").Preload(\"Server\").Where(\"id = ?\", id).First(&link).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &link, nil\n}\n\nfunc (r *pgRepository) GetServerByURL(rawURL string) (*account.RMSServer, error) {\n\tcleanURL := strings.TrimRight(strings.ToLower(strings.TrimSpace(rawURL)), \"/\")\n\tvar server account.RMSServer\n\terr := r.db.Where(\"base_url = ?\", cleanURL).First(&server).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &server, nil\n}\n\nfunc (r *pgRepository) GetServerByID(id uuid.UUID) (*account.RMSServer, error) {\n\tvar server account.RMSServer\n\terr := r.db.First(&server, id).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &server, nil\n}\n\n// UpdateBalance начисляет пакет или продлевает подписку\nfunc (r *pgRepository) UpdateBalance(serverID uuid.UUID, amountChange int, newPaidUntil *time.Time) error {\n\treturn r.db.Model(&account.RMSServer{}).Where(\"id = ?\", serverID).Updates(map[string]interface{}{\n\t\t\"balance\": gorm.Expr(\"balance + ?\", amountChange),\n\t\t\"paid_until\": newPaidUntil,\n\t}).Error\n}\n\n// DecrementBalance списывает 1 единицу при отправке накладной\nfunc (r *pgRepository) DecrementBalance(serverID uuid.UUID) error {\n\treturn r.db.Model(&account.RMSServer{}).\n\t\tWhere(\"id = ? AND balance > 0\", serverID).\n\t\tUpdateColumn(\"balance\", gorm.Expr(\"balance - ?\", 1)).Error\n}\n", + "internal/infrastructure/repository/billing/postgres.go": "package billing\n\nimport (\n\t\"rmser/internal/domain/billing\"\n\n\t\"github.com/google/uuid\"\n\t\"gorm.io/gorm\"\n)\n\ntype pgRepository struct {\n\tdb *gorm.DB\n}\n\nfunc NewRepository(db *gorm.DB) billing.Repository {\n\treturn &pgRepository{db: db}\n}\n\nfunc (r *pgRepository) CreateOrder(order *billing.Order) error {\n\treturn r.db.Create(order).Error\n}\n\nfunc (r *pgRepository) GetOrder(id uuid.UUID) (*billing.Order, error) {\n\tvar order billing.Order\n\terr := r.db.Where(\"id = ?\", id).First(&order).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &order, nil\n}\n\nfunc (r *pgRepository) UpdateOrderStatus(id uuid.UUID, status billing.OrderStatus, paymentID string) error {\n\tupdates := map[string]interface{}{\n\t\t\"status\": status,\n\t}\n\tif paymentID != \"\" {\n\t\tupdates[\"payment_id\"] = paymentID\n\t}\n\treturn r.db.Model(&billing.Order{}).Where(\"id = ?\", id).Updates(updates).Error\n}\n\nfunc (r *pgRepository) GetActiveOrdersByUser(userID uuid.UUID) ([]billing.Order, error) {\n\tvar orders []billing.Order\n\terr := r.db.Where(\"user_id = ? AND status = ?\", userID, billing.StatusPending).\n\t\tOrder(\"created_at DESC\").\n\t\tFind(&orders).Error\n\treturn orders, err\n}\n", + "internal/infrastructure/repository/catalog/postgres.go": "package catalog\n\nimport (\n\t\"rmser/internal/domain/catalog\"\n\n\t\"github.com/google/uuid\"\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/clause\"\n)\n\ntype pgRepository struct {\n\tdb *gorm.DB\n}\n\nfunc NewRepository(db *gorm.DB) catalog.Repository {\n\treturn &pgRepository{db: db}\n}\n\n// --- Запись (Save) ---\n// При сохранении мы предполагаем, что serverID уже проставлен в Entity в слое Service.\n// Но для надежности можно передавать serverID в метод Save, однако Service должен это контролировать.\n// Оставим контракт Save(products []Product), где внутри products уже заполнен RMSServerID.\n\nfunc (r *pgRepository) SaveMeasureUnits(units []catalog.MeasureUnit) error {\n\tif len(units) == 0 {\n\t\treturn nil\n\t}\n\treturn r.db.Clauses(clause.OnConflict{\n\t\tColumns: []clause.Column{{Name: \"id\"}}, // ID глобально уникален (UUID), конфликтов между серверами не будет\n\t\tUpdateAll: true,\n\t}).CreateInBatches(units, 100).Error\n}\n\nfunc (r *pgRepository) SaveProducts(products []catalog.Product) error {\n\tsorted := sortProductsByHierarchy(products)\n\treturn r.db.Transaction(func(tx *gorm.DB) error {\n\t\t// 1. Продукты\n\t\tif err := tx.Omit(\"Containers\").Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tUpdateAll: true,\n\t\t}).CreateInBatches(sorted, 100).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// 2. Контейнеры\n\t\tvar allContainers []catalog.ProductContainer\n\t\tfor _, p := range products {\n\t\t\tallContainers = append(allContainers, p.Containers...)\n\t\t}\n\n\t\tif len(allContainers) > 0 {\n\t\t\tif err := tx.Clauses(clause.OnConflict{\n\t\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\t\tUpdateAll: true,\n\t\t\t}).CreateInBatches(allContainers, 100).Error; err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc (r *pgRepository) SaveContainer(container catalog.ProductContainer) error {\n\treturn r.db.Clauses(clause.OnConflict{\n\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\tUpdateAll: true,\n\t}).Create(&container).Error\n}\n\nfunc (r *pgRepository) SaveStores(stores []catalog.Store) error {\n\tif len(stores) == 0 {\n\t\treturn nil\n\t}\n\treturn r.db.Clauses(clause.OnConflict{\n\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\tUpdateAll: true,\n\t}).CreateInBatches(stores, 100).Error\n}\n\n// --- Чтение (Read) с фильтрацией по ServerID ---\n\nfunc (r *pgRepository) GetAll() ([]catalog.Product, error) {\n\t// Этот метод был legacy и грузил всё. Теперь он опасен без serverID.\n\t// Оставляем заглушку или удаляем. Лучше удалить из интерфейса, но пока вернем пустой список\n\t// чтобы не ломать сборку, пока не почистим вызовы.\n\treturn nil, nil\n}\n\nfunc (r *pgRepository) GetActiveGoods(serverID uuid.UUID, rootGroupID *uuid.UUID) ([]catalog.Product, error) {\n\tvar products []catalog.Product\n\tdb := r.db.Preload(\"MainUnit\").Preload(\"Containers\").\n\t\tWhere(\"rms_server_id = ? AND is_deleted = ? AND type IN ?\", serverID, false, []string{\"GOODS\"})\n\n\tif rootGroupID != nil && *rootGroupID != uuid.Nil {\n\t\t// Используем Recursive CTE для поиска всех дочерних элементов папки\n\t\tsubQuery := r.db.Raw(`\n\t\t\tWITH RECURSIVE subnodes AS (\n\t\t\t\tSELECT id FROM products WHERE id = ?\n\t\t\t\tUNION ALL\n\t\t\t\tSELECT p.id FROM products p INNER JOIN subnodes s ON p.parent_id = s.id\n\t\t\t)\n\t\t\tSELECT id FROM subnodes\n\t\t`, rootGroupID)\n\t\tdb = db.Where(\"id IN (?)\", subQuery)\n\t}\n\n\terr := db.Order(\"name ASC\").Find(&products).Error\n\treturn products, err\n}\n\nfunc (r *pgRepository) GetActiveStores(serverID uuid.UUID) ([]catalog.Store, error) {\n\tvar stores []catalog.Store\n\terr := r.db.Where(\"rms_server_id = ? AND is_deleted = ?\", serverID, false).Order(\"name ASC\").Find(&stores).Error\n\treturn stores, err\n}\n\nfunc (r *pgRepository) Search(serverID uuid.UUID, query string, rootGroupID *uuid.UUID) ([]catalog.Product, error) {\n\tvar products []catalog.Product\n\tq := \"%\" + query + \"%\"\n\n\tdb := r.db.Preload(\"MainUnit\").Preload(\"Containers\").\n\t\tWhere(\"rms_server_id = ? AND is_deleted = ? AND type = ?\", serverID, false, \"GOODS\").\n\t\tWhere(\"(name ILIKE ? OR code ILIKE ? OR num ILIKE ?)\", q, q, q)\n\n\tif rootGroupID != nil && *rootGroupID != uuid.Nil {\n\t\tsubQuery := r.db.Raw(`\n\t\t\tWITH RECURSIVE subnodes AS (\n\t\t\t\tSELECT id FROM products WHERE id = ?\n\t\t\t\tUNION ALL\n\t\t\t\tSELECT p.id FROM products p INNER JOIN subnodes s ON p.parent_id = s.id\n\t\t\t)\n\t\t\tSELECT id FROM subnodes\n\t\t`, rootGroupID)\n\t\tdb = db.Where(\"id IN (?)\", subQuery)\n\t}\n\n\terr := db.Order(\"name ASC\").Limit(20).Find(&products).Error\n\treturn products, err\n}\n\n// sortProductsByHierarchy - вспомогательная функция, оставляем как есть (копипаст из старого файла)\nfunc sortProductsByHierarchy(products []catalog.Product) []catalog.Product {\n\tif len(products) == 0 {\n\t\treturn products\n\t}\n\tchildrenMap := make(map[uuid.UUID][]catalog.Product)\n\tvar roots []catalog.Product\n\tallIDs := make(map[uuid.UUID]struct{}, len(products))\n\n\tfor _, p := range products {\n\t\tallIDs[p.ID] = struct{}{}\n\t}\n\n\tfor _, p := range products {\n\t\tif p.ParentID == nil {\n\t\t\troots = append(roots, p)\n\t\t} else {\n\t\t\tif _, exists := allIDs[*p.ParentID]; exists {\n\t\t\t\tchildrenMap[*p.ParentID] = append(childrenMap[*p.ParentID], p)\n\t\t\t} else {\n\t\t\t\troots = append(roots, p)\n\t\t\t}\n\t\t}\n\t}\n\n\tresult := make([]catalog.Product, 0, len(products))\n\tqueue := roots\n\tfor len(queue) > 0 {\n\t\tcurrent := queue[0]\n\t\tqueue = queue[1:]\n\t\tresult = append(result, current)\n\t\tif children, ok := childrenMap[current.ID]; ok {\n\t\t\tqueue = append(queue, children...)\n\t\t\tdelete(childrenMap, current.ID)\n\t\t}\n\t}\n\tfor _, remaining := range childrenMap {\n\t\tresult = append(result, remaining...)\n\t}\n\treturn result\n}\n\nfunc (r *pgRepository) CountGoods(serverID uuid.UUID) (int64, error) {\n\tvar count int64\n\terr := r.db.Model(&catalog.Product{}).\n\t\tWhere(\"rms_server_id = ? AND type IN ? AND is_deleted = ?\", serverID, []string{\"GOODS\"}, false).\n\t\tCount(&count).Error\n\treturn count, err\n}\n\nfunc (r *pgRepository) CountStores(serverID uuid.UUID) (int64, error) {\n\tvar count int64\n\terr := r.db.Model(&catalog.Store{}).\n\t\tWhere(\"rms_server_id = ? AND is_deleted = ?\", serverID, false).\n\t\tCount(&count).Error\n\treturn count, err\n}\n\nfunc (r *pgRepository) GetGroups(serverID uuid.UUID) ([]catalog.Product, error) {\n\tvar groups []catalog.Product\n\t// iiko присылает группы с типом \"GROUP\"\n\terr := r.db.Where(\"rms_server_id = ? AND type = ? AND is_deleted = ?\", serverID, \"GROUP\", false).\n\t\tOrder(\"name ASC\").\n\t\tFind(&groups).Error\n\treturn groups, err\n}\n", + "internal/infrastructure/repository/drafts/postgres.go": "package drafts\n\nimport (\n\t\"rmser/internal/domain/drafts\"\n\n\t\"github.com/google/uuid\"\n\t\"gorm.io/gorm\"\n)\n\ntype pgRepository struct {\n\tdb *gorm.DB\n}\n\nfunc NewRepository(db *gorm.DB) drafts.Repository {\n\treturn &pgRepository{db: db}\n}\n\nfunc (r *pgRepository) Create(draft *drafts.DraftInvoice) error {\n\treturn r.db.Create(draft).Error\n}\n\nfunc (r *pgRepository) GetByID(id uuid.UUID) (*drafts.DraftInvoice, error) {\n\tvar draft drafts.DraftInvoice\n\terr := r.db.\n\t\tPreload(\"Items\", func(db *gorm.DB) *gorm.DB {\n\t\t\treturn db.Order(\"draft_invoice_items.raw_name ASC\")\n\t\t}).\n\t\tPreload(\"Items.Product\").\n\t\tPreload(\"Items.Product.MainUnit\").\n\t\tPreload(\"Items.Container\").\n\t\tWhere(\"id = ?\", id).\n\t\tFirst(&draft).Error\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &draft, nil\n}\n\nfunc (r *pgRepository) GetByRMSInvoiceID(rmsInvoiceID uuid.UUID) (*drafts.DraftInvoice, error) {\n\tvar draft drafts.DraftInvoice\n\terr := r.db.\n\t\tWhere(\"rms_invoice_id = ?\", rmsInvoiceID).\n\t\tFirst(&draft).Error\n\tif err != nil {\n\t\tif err == gorm.ErrRecordNotFound {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\treturn &draft, nil\n}\n\nfunc (r *pgRepository) Update(draft *drafts.DraftInvoice) error {\n\treturn r.db.Model(draft).Updates(map[string]interface{}{\n\t\t\"status\": draft.Status,\n\t\t\"document_number\": draft.DocumentNumber,\n\t\t\"incoming_document_number\": draft.IncomingDocumentNumber,\n\t\t\"date_incoming\": draft.DateIncoming,\n\t\t\"supplier_id\": draft.SupplierID,\n\t\t\"store_id\": draft.StoreID,\n\t\t\"comment\": draft.Comment,\n\t\t\"rms_invoice_id\": draft.RMSInvoiceID,\n\t\t\"rms_server_id\": draft.RMSServerID,\n\t\t\"updated_at\": gorm.Expr(\"NOW()\"),\n\t}).Error\n}\n\nfunc (r *pgRepository) CreateItems(items []drafts.DraftInvoiceItem) error {\n\tif len(items) == 0 {\n\t\treturn nil\n\t}\n\treturn r.db.CreateInBatches(items, 100).Error\n}\n\nfunc (r *pgRepository) CreateItem(item *drafts.DraftInvoiceItem) error {\n\treturn r.db.Create(item).Error\n}\n\nfunc (r *pgRepository) DeleteItem(itemID uuid.UUID) error {\n\treturn r.db.Delete(&drafts.DraftInvoiceItem{}, itemID).Error\n}\n\n// GetItemByID - новый метод\nfunc (r *pgRepository) GetItemByID(itemID uuid.UUID) (*drafts.DraftInvoiceItem, error) {\n\tvar item drafts.DraftInvoiceItem\n\terr := r.db.Where(\"id = ?\", itemID).First(&item).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\n// UpdateItem - обновленный метод, принимает map\nfunc (r *pgRepository) UpdateItem(itemID uuid.UUID, updates map[string]interface{}) error {\n\treturn r.db.Model(&drafts.DraftInvoiceItem{}).\n\t\tWhere(\"id = ?\", itemID).\n\t\tUpdates(updates).Error\n}\n\nfunc (r *pgRepository) Delete(id uuid.UUID) error {\n\treturn r.db.Delete(&drafts.DraftInvoice{}, id).Error\n}\n\nfunc (r *pgRepository) GetActive(serverID uuid.UUID) ([]drafts.DraftInvoice, error) {\n\tvar list []drafts.DraftInvoice\n\n\tactiveStatuses := []string{\n\t\tdrafts.StatusProcessing,\n\t\tdrafts.StatusReadyToVerify,\n\t\tdrafts.StatusError,\n\t\tdrafts.StatusCanceled,\n\t}\n\n\terr := r.db.\n\t\tPreload(\"Items\").\n\t\tPreload(\"Store\").\n\t\tWhere(\"rms_server_id = ? AND status IN ?\", serverID, activeStatuses).\n\t\tOrder(\"created_at DESC\").\n\t\tFind(&list).Error\n\n\treturn list, err\n}\n\nfunc (r *pgRepository) GetRMSInvoiceIDToPhotoURLMap(serverID uuid.UUID) (map[uuid.UUID]string, error) {\n\tvar draftsList []drafts.DraftInvoice\n\terr := r.db.\n\t\tSelect(\"rms_invoice_id\", \"sender_photo_url\").\n\t\tWhere(\"rms_server_id = ? AND rms_invoice_id IS NOT NULL\", serverID).\n\t\tFind(&draftsList).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tresult := make(map[uuid.UUID]string)\n\tfor _, d := range draftsList {\n\t\tif d.RMSInvoiceID != nil {\n\t\t\tresult[*d.RMSInvoiceID] = d.SenderPhotoURL\n\t\t}\n\t}\n\treturn result, nil\n}\n", + "internal/infrastructure/repository/invoices/postgres.go": "package invoices\n\nimport (\n\t\"time\"\n\n\t\"rmser/internal/domain/invoices\"\n\n\t\"github.com/google/uuid\"\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/clause\"\n)\n\ntype pgRepository struct {\n\tdb *gorm.DB\n}\n\nfunc NewRepository(db *gorm.DB) invoices.Repository {\n\treturn &pgRepository{db: db}\n}\n\nfunc (r *pgRepository) GetByID(id uuid.UUID) (*invoices.Invoice, error) {\n\tvar inv invoices.Invoice\n\terr := r.db.\n\t\tPreload(\"Items\").\n\t\tPreload(\"Items.Product\").\n\t\tWhere(\"id = ?\", id).\n\t\tFirst(&inv).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &inv, nil\n}\n\nfunc (r *pgRepository) GetLastInvoiceDate(serverID uuid.UUID) (*time.Time, error) {\n\tvar inv invoices.Invoice\n\t// Ищем последнюю накладную только для этого сервера\n\terr := r.db.Where(\"rms_server_id = ?\", serverID).Order(\"date_incoming DESC\").First(&inv).Error\n\tif err != nil {\n\t\tif err == gorm.ErrRecordNotFound {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\treturn &inv.DateIncoming, nil\n}\n\nfunc (r *pgRepository) GetByPeriod(serverID uuid.UUID, from, to time.Time) ([]invoices.Invoice, error) {\n\tvar list []invoices.Invoice\n\terr := r.db.\n\t\tPreload(\"Items\").\n\t\tPreload(\"Items.Product\").\n\t\tWhere(\"rms_server_id = ? AND date_incoming BETWEEN ? AND ? AND status != ?\", serverID, from, to, \"DELETED\").\n\t\tOrder(\"date_incoming DESC\").\n\t\tFind(&list).Error\n\treturn list, err\n}\n\nfunc (r *pgRepository) SaveInvoices(list []invoices.Invoice) error {\n\treturn r.db.Transaction(func(tx *gorm.DB) error {\n\t\tfor _, inv := range list {\n\t\t\tif err := tx.Omit(\"Items\").Clauses(clause.OnConflict{\n\t\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\t\tUpdateAll: true,\n\t\t\t}).Create(&inv).Error; err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\t// Удаляем старые Items для этой накладной\n\t\t\tif err := tx.Where(\"invoice_id = ?\", inv.ID).Delete(&invoices.InvoiceItem{}).Error; err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif len(inv.Items) > 0 {\n\t\t\t\tif err := tx.Create(&inv.Items).Error; err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc (r *pgRepository) CountRecent(serverID uuid.UUID, days int) (int64, error) {\n\tvar count int64\n\tdateFrom := time.Now().AddDate(0, 0, -days)\n\n\terr := r.db.Model(&invoices.Invoice{}).\n\t\tWhere(\"rms_server_id = ? AND date_incoming >= ?\", serverID, dateFrom).\n\t\tCount(&count).Error\n\treturn count, err\n}\n", + "internal/infrastructure/repository/ocr/postgres.go": "package ocr\n\nimport (\n\t\"strings\"\n\t\"time\"\n\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/clause\"\n\n\t\"rmser/internal/domain/ocr\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/shopspring/decimal\"\n)\n\ntype pgRepository struct {\n\tdb *gorm.DB\n}\n\nfunc NewRepository(db *gorm.DB) ocr.Repository {\n\treturn &pgRepository{db: db}\n}\n\nfunc (r *pgRepository) SaveMatch(serverID uuid.UUID, rawName string, productID uuid.UUID, quantity decimal.Decimal, containerID *uuid.UUID) error {\n\tnormalized := strings.ToLower(strings.TrimSpace(rawName))\n\n\tmatch := ocr.ProductMatch{\n\t\tRMSServerID: serverID,\n\t\tRawName: normalized,\n\t\tProductID: productID,\n\t\tQuantity: quantity,\n\t\tContainerID: containerID,\n\t}\n\n\treturn r.db.Transaction(func(tx *gorm.DB) error {\n\t\t// Используем OnConflict по составному индексу (raw_name, rms_server_id)\n\t\t// Но GORM может потребовать названия ограничения.\n\t\t// Проще сделать через Where().Assign().FirstOrCreate() или явно указать Columns если индекс есть.\n\t\t// В Entity мы указали `uniqueIndex:idx_raw_server`.\n\n\t\tif err := tx.Clauses(clause.OnConflict{\n\t\t\t// Указываем оба поля, входящие в unique index\n\t\t\tColumns: []clause.Column{{Name: \"raw_name\"}, {Name: \"rms_server_id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"product_id\", \"quantity\", \"container_id\", \"updated_at\"}),\n\t\t}).Create(&match).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Удаляем из Unmatched для этого сервера\n\t\tif err := tx.Where(\"rms_server_id = ? AND raw_name = ?\", serverID, normalized).Delete(&ocr.UnmatchedItem{}).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc (r *pgRepository) DeleteMatch(serverID uuid.UUID, rawName string) error {\n\tnormalized := strings.ToLower(strings.TrimSpace(rawName))\n\treturn r.db.Where(\"rms_server_id = ? AND raw_name = ?\", serverID, normalized).Delete(&ocr.ProductMatch{}).Error\n}\n\nfunc (r *pgRepository) FindMatch(serverID uuid.UUID, rawName string) (*ocr.ProductMatch, error) {\n\tnormalized := strings.ToLower(strings.TrimSpace(rawName))\n\tvar match ocr.ProductMatch\n\n\terr := r.db.Preload(\"Container\").\n\t\tWhere(\"rms_server_id = ? AND raw_name = ?\", serverID, normalized).\n\t\tFirst(&match).Error\n\n\tif err != nil {\n\t\tif err == gorm.ErrRecordNotFound {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\treturn &match, nil\n}\n\nfunc (r *pgRepository) GetAllMatches(serverID uuid.UUID) ([]ocr.ProductMatch, error) {\n\tvar matches []ocr.ProductMatch\n\terr := r.db.\n\t\tPreload(\"Product\").\n\t\tPreload(\"Product.MainUnit\").\n\t\tPreload(\"Product.Containers\").\n\t\tPreload(\"Container\").\n\t\tWhere(\"rms_server_id = ?\", serverID).\n\t\tOrder(\"updated_at DESC\").\n\t\tFind(&matches).Error\n\treturn matches, err\n}\n\nfunc (r *pgRepository) UpsertUnmatched(serverID uuid.UUID, rawName string) error {\n\tnormalized := strings.ToLower(strings.TrimSpace(rawName))\n\tif normalized == \"\" {\n\t\treturn nil\n\t}\n\n\titem := ocr.UnmatchedItem{\n\t\tRMSServerID: serverID,\n\t\tRawName: normalized,\n\t\tCount: 1,\n\t\tLastSeen: time.Now(),\n\t}\n\n\treturn r.db.Clauses(clause.OnConflict{\n\t\tColumns: []clause.Column{{Name: \"raw_name\"}, {Name: \"rms_server_id\"}},\n\t\tDoUpdates: clause.Assignments(map[string]interface{}{\n\t\t\t\"count\": gorm.Expr(\"unmatched_items.count + 1\"),\n\t\t\t\"last_seen\": time.Now(),\n\t\t}),\n\t}).Create(&item).Error\n}\n\nfunc (r *pgRepository) GetTopUnmatched(serverID uuid.UUID, limit int) ([]ocr.UnmatchedItem, error) {\n\tvar items []ocr.UnmatchedItem\n\terr := r.db.Where(\"rms_server_id = ?\", serverID).\n\t\tOrder(\"count DESC, last_seen DESC\").\n\t\tLimit(limit).\n\t\tFind(&items).Error\n\treturn items, err\n}\n\nfunc (r *pgRepository) DeleteUnmatched(serverID uuid.UUID, rawName string) error {\n\tnormalized := strings.ToLower(strings.TrimSpace(rawName))\n\treturn r.db.Where(\"rms_server_id = ? AND raw_name = ?\", serverID, normalized).Delete(&ocr.UnmatchedItem{}).Error\n}\n", + "internal/infrastructure/repository/operations/postgres.go": "package operations\n\nimport (\n\t\"time\"\n\n\t\"rmser/internal/domain/operations\"\n\n\t\"github.com/google/uuid\"\n\n\t\"gorm.io/gorm\"\n)\n\ntype pgRepository struct {\n\tdb *gorm.DB\n}\n\nfunc NewRepository(db *gorm.DB) operations.Repository {\n\treturn &pgRepository{db: db}\n}\n\nfunc (r *pgRepository) SaveOperations(ops []operations.StoreOperation, serverID uuid.UUID, opType operations.OperationType, dateFrom, dateTo time.Time) error {\n\treturn r.db.Transaction(func(tx *gorm.DB) error {\n\t\t// Удаляем старые записи этого типа, но ТОЛЬКО для конкретного сервера\n\t\tif err := tx.Where(\"rms_server_id = ? AND op_type = ? AND period_from >= ? AND period_to <= ?\",\n\t\t\tserverID, opType, dateFrom, dateTo).\n\t\t\tDelete(&operations.StoreOperation{}).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif len(ops) > 0 {\n\t\t\tif err := tx.CreateInBatches(ops, 500).Error; err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n", + "internal/infrastructure/repository/photos/postgres.go": "package photos\n\nimport (\n\t\"rmser/internal/domain/photos\"\n\n\t\"github.com/google/uuid\"\n\t\"gorm.io/gorm\"\n)\n\ntype pgRepository struct {\n\tdb *gorm.DB\n}\n\nfunc NewRepository(db *gorm.DB) photos.Repository {\n\treturn &pgRepository{db: db}\n}\n\nfunc (r *pgRepository) Create(photo *photos.ReceiptPhoto) error {\n\treturn r.db.Create(photo).Error\n}\n\nfunc (r *pgRepository) GetByID(id uuid.UUID) (*photos.ReceiptPhoto, error) {\n\tvar photo photos.ReceiptPhoto\n\terr := r.db.First(&photo, id).Error\n\treturn &photo, err\n}\n\nfunc (r *pgRepository) GetByServerID(serverID uuid.UUID, page, limit int) ([]photos.ReceiptPhoto, int64, error) {\n\tvar items []photos.ReceiptPhoto\n\tvar total int64\n\n\toffset := (page - 1) * limit\n\n\terr := r.db.Model(&photos.ReceiptPhoto{}).\n\t\tWhere(\"rms_server_id = ?\", serverID).\n\t\tCount(&total).Error\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\terr = r.db.Where(\"rms_server_id = ?\", serverID).\n\t\tOrder(\"created_at DESC\").\n\t\tOffset(offset).\n\t\tLimit(limit).\n\t\tFind(&items).Error\n\n\treturn items, total, err\n}\n\nfunc (r *pgRepository) UpdateDraftLink(photoID uuid.UUID, draftID *uuid.UUID) error {\n\treturn r.db.Model(&photos.ReceiptPhoto{}).\n\t\tWhere(\"id = ?\", photoID).\n\t\tUpdate(\"draft_id\", draftID).Error\n}\n\nfunc (r *pgRepository) UpdateInvoiceLink(photoID uuid.UUID, invoiceID *uuid.UUID) error {\n\treturn r.db.Model(&photos.ReceiptPhoto{}).\n\t\tWhere(\"id = ?\", photoID).\n\t\tUpdate(\"invoice_id\", invoiceID).Error\n}\n\nfunc (r *pgRepository) ClearDraftLinkByDraftID(draftID uuid.UUID) error {\n\treturn r.db.Model(&photos.ReceiptPhoto{}).\n\t\tWhere(\"draft_id = ?\", draftID).\n\t\tUpdate(\"draft_id\", nil).Error\n}\n\nfunc (r *pgRepository) Delete(id uuid.UUID) error {\n\treturn r.db.Delete(&photos.ReceiptPhoto{}, id).Error\n}\n", + "internal/infrastructure/repository/recipes/postgres.go": "package recipes\n\nimport (\n\t\"rmser/internal/domain/recipes\"\n\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/clause\"\n)\n\ntype pgRepository struct {\n\tdb *gorm.DB\n}\n\nfunc NewRepository(db *gorm.DB) recipes.Repository {\n\treturn &pgRepository{db: db}\n}\n\n// Техкарты сохраняются пачкой, serverID внутри структуры\nfunc (r *pgRepository) SaveRecipes(list []recipes.Recipe) error {\n\treturn r.db.Transaction(func(tx *gorm.DB) error {\n\t\tfor _, recipe := range list {\n\t\t\tif err := tx.Omit(\"Items\").Clauses(clause.OnConflict{\n\t\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\t\tUpdateAll: true,\n\t\t\t}).Create(&recipe).Error; err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif err := tx.Where(\"recipe_id = ?\", recipe.ID).Delete(&recipes.RecipeItem{}).Error; err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif len(recipe.Items) > 0 {\n\t\t\t\tif err := tx.Create(&recipe.Items).Error; err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n", + "internal/infrastructure/repository/recommendations/postgres.go": "package recommendations\n\nimport (\n\t\"fmt\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"rmser/internal/domain/operations\"\n\t\"rmser/internal/domain/recommendations\"\n\n\t\"gorm.io/gorm\"\n)\n\ntype pgRepository struct {\n\tdb *gorm.DB\n}\n\nfunc NewRepository(db *gorm.DB) recommendations.Repository {\n\treturn &pgRepository{db: db}\n}\n\n// --- Методы Хранения ---\n\nfunc (r *pgRepository) SaveAll(items []recommendations.Recommendation) error {\n\treturn r.db.Transaction(func(tx *gorm.DB) error {\n\t\tif err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Delete(&recommendations.Recommendation{}).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif len(items) > 0 {\n\t\t\tif err := tx.CreateInBatches(items, 100).Error; err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc (r *pgRepository) GetAll() ([]recommendations.Recommendation, error) {\n\tvar items []recommendations.Recommendation\n\terr := r.db.Find(&items).Error\n\treturn items, err\n}\n\n// --- Методы Аналитики ---\n\n// 1. Товары (GOODS/PREPARED), не используемые в техкартах\nfunc (r *pgRepository) FindUnusedGoods() ([]recommendations.Recommendation, error) {\n\tvar results []recommendations.Recommendation\n\n\tquery := `\n\t\tSELECT \n\t\t\tp.id as product_id, \n\t\t\tp.name as product_name,\n\t\t\t'Товар не используется ни в одной техкарте' as reason,\n\t\t\t? as type\n\t\tFROM products p\n\t\tWHERE p.type IN ('GOODS', 'PREPARED') \n\t\t AND p.is_deleted = false -- Проверка на удаление\n\t\t AND p.id NOT IN (\n\t\t SELECT DISTINCT product_id FROM recipe_items\n\t\t )\n\t\t AND p.id NOT IN (\n\t\t SELECT DISTINCT product_id FROM recipes\n\t\t )\n\t\tORDER BY p.name ASC\n\t`\n\n\tif err := r.db.Raw(query, recommendations.TypeUnused).Scan(&results).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn results, nil\n}\n\n// 2. Закупается, но нет в техкартах\nfunc (r *pgRepository) FindPurchasedButUnused(days int) ([]recommendations.Recommendation, error) {\n\tvar results []recommendations.Recommendation\n\tdateFrom := time.Now().AddDate(0, 0, -days)\n\n\tquery := `\n\t\tSELECT DISTINCT\n\t\t\tp.id as product_id,\n\t\t\tp.name as product_name,\n\t\t\t'Товар активно закупается, но не включен ни в одну техкарту' as reason,\n\t\t\t? as type\n\t\tFROM store_operations so\n\t\tJOIN products p ON so.product_id = p.id\n\t\tWHERE \n\t\t\tso.op_type = ? \n\t\t\tAND so.period_from >= ?\n\t\t\tAND p.is_deleted = false -- Проверка на удаление\n\t\t\tAND p.id NOT IN ( \n\t\t\t\tSELECT DISTINCT product_id FROM recipe_items\n\t\t\t)\n\t\tORDER BY p.name ASC\n\t`\n\n\tif err := r.db.Raw(query, recommendations.TypePurchasedButUnused, operations.OpTypePurchase, dateFrom).Scan(&results).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn results, nil\n}\n\n// 3. Ингредиенты в актуальных техкартах без закупок\nfunc (r *pgRepository) FindNoIncomingIngredients(days int) ([]recommendations.Recommendation, error) {\n\tvar results []recommendations.Recommendation\n\tdateFrom := time.Now().AddDate(0, 0, -days)\n\n\tquery := `\n\t\tSELECT\n\t\t\tp.id as product_id,\n\t\t\tp.name as product_name,\n\t\t\t'Нет закупок (' || ? || ' дн). Входит в: ' || STRING_AGG(DISTINCT parent.name, ', ') as reason,\n\t\t\t? as type\n\t\tFROM recipe_items ri\n\t\tJOIN recipes r ON ri.recipe_id = r.id\n\t\tJOIN products p ON ri.product_id = p.id\n\t\tJOIN products parent ON r.product_id = parent.id\n\t\tWHERE \n\t\t\t(r.date_to IS NULL OR r.date_to >= CURRENT_DATE)\n\t\t\tAND p.type = 'GOODS'\n\t\t\tAND p.is_deleted = false -- Сам ингредиент не удален\n\t\t\tAND parent.is_deleted = false -- Блюдо, в которое он входит, не удалено\n\t\t\tAND p.id NOT IN (\n\t\t\t\tSELECT product_id \n\t\t\t\tFROM store_operations \n\t\t\t\tWHERE op_type = ? \n\t\t\t\t AND period_from >= ?\n\t\t\t)\n\t\tGROUP BY p.id, p.name\n\t\tORDER BY p.name ASC\n\t`\n\n\tif err := r.db.Raw(query, strconv.Itoa(days), recommendations.TypeNoIncoming, operations.OpTypePurchase, dateFrom).Scan(&results).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn results, nil\n}\n\n// 4. Товары, которые закупаем, но не расходуем (\"Висяки\")\nfunc (r *pgRepository) FindStaleGoods(days int) ([]recommendations.Recommendation, error) {\n\tvar results []recommendations.Recommendation\n\tdateFrom := time.Now().AddDate(0, 0, -days)\n\n\tquery := `\n\t\tSELECT DISTINCT\n\t\t\tp.id as product_id,\n\t\t\tp.name as product_name,\n\t\t\t? as reason,\n\t\t\t? as type\n\t\tFROM store_operations so\n\t\tJOIN products p ON so.product_id = p.id\n\t\tWHERE \n\t\t\tso.op_type = ? \n\t\t\tAND so.period_from >= ?\n\t\t\tAND p.is_deleted = false -- Проверка на удаление\n\t\t\tAND p.id NOT IN ( \n\t\t\t\tSELECT product_id \n\t\t\t\tFROM store_operations \n\t\t\t\tWHERE op_type = ? \n\t\t\t\t AND period_from >= ?\n\t\t\t)\n\t\tORDER BY p.name ASC\n\t`\n\n\treason := fmt.Sprintf(\"Были закупки, но нет расхода за %d дн.\", days)\n\n\tif err := r.db.Raw(query, reason, recommendations.TypeStale, operations.OpTypePurchase, dateFrom, operations.OpTypeUsage, dateFrom).\n\t\tScan(&results).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn results, nil\n}\n\n// 5. Блюдо используется в техкарте другого блюда\nfunc (r *pgRepository) FindDishesInRecipes() ([]recommendations.Recommendation, error) {\n\tvar results []recommendations.Recommendation\n\n\tquery := `\n\t\tSELECT DISTINCT\n\t\t\tchild.id as product_id,\n\t\t\tchild.name as product_name,\n\t\t\t'Является Блюдом (DISH), но указан ингредиентом в: ' || parent.name as reason,\n\t\t\t? as type\n\t\tFROM recipe_items ri\n\t\tJOIN products child ON ri.product_id = child.id\n\t\tJOIN recipes r ON ri.recipe_id = r.id\n\t\tJOIN products parent ON r.product_id = parent.id\n\t\tWHERE \n\t\t\tchild.type = 'DISH'\n\t\t\tAND child.is_deleted = false -- Вложенное блюдо не удалено\n\t\t\tAND parent.is_deleted = false -- Родительское блюдо не удалено\n\t\t\tAND (r.date_to IS NULL OR r.date_to >= CURRENT_DATE)\n\t\tORDER BY child.name ASC\n\t`\n\n\tif err := r.db.Raw(query, recommendations.TypeDishInRecipe).Scan(&results).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn results, nil\n}\n\n// 6. Есть расход (Usage), но нет прихода (Purchase)\nfunc (r *pgRepository) FindUsageWithoutPurchase(days int) ([]recommendations.Recommendation, error) {\n\tvar results []recommendations.Recommendation\n\tdateFrom := time.Now().AddDate(0, 0, -days)\n\n\tquery := `\n\t\tSELECT DISTINCT\n\t\t\tp.id as product_id,\n\t\t\tp.name as product_name,\n\t\t\t? as reason,\n\t\t\t? as type\n\t\tFROM store_operations so\n\t\tJOIN products p ON so.product_id = p.id\n\t\tWHERE \n\t\t\tso.op_type = ? -- Есть расход (продажа/списание)\n\t\t\tAND so.period_from >= ?\n\t\t\tAND p.type = 'GOODS' -- Только для товаров\n\t\t\tAND p.is_deleted = false -- Товар жив\n\t\t\tAND p.id NOT IN ( -- Но не было закупок\n\t\t\t\tSELECT product_id \n\t\t\t\tFROM store_operations \n\t\t\t\tWHERE op_type = ? \n\t\t\t\t AND period_from >= ?\n\t\t\t)\n\t\tORDER BY p.name ASC\n\t`\n\n\treason := fmt.Sprintf(\"Товар расходуется (продажи/списания), но не закупался последние %d дн.\", days)\n\n\t// Аргументы: reason, type, OpUsage, date, OpPurchase, date\n\tif err := r.db.Raw(query,\n\t\treason,\n\t\trecommendations.TypeUsageNoIncoming,\n\t\toperations.OpTypeUsage,\n\t\tdateFrom,\n\t\toperations.OpTypePurchase,\n\t\tdateFrom,\n\t).Scan(&results).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn results, nil\n}\n", + "internal/infrastructure/repository/suppliers/postgres.go": "package suppliers\n\nimport (\n\t\"rmser/internal/domain/suppliers\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/clause\"\n)\n\ntype pgRepository struct {\n\tdb *gorm.DB\n}\n\nfunc NewRepository(db *gorm.DB) suppliers.Repository {\n\treturn &pgRepository{db: db}\n}\n\nfunc (r *pgRepository) SaveBatch(list []suppliers.Supplier) error {\n\tif len(list) == 0 {\n\t\treturn nil\n\t}\n\treturn r.db.Clauses(clause.OnConflict{\n\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\tUpdateAll: true,\n\t}).CreateInBatches(list, 100).Error\n}\n\n// GetRankedByUsage возвращает поставщиков для конкретного сервера,\n// отсортированных по количеству накладных за последние N дней.\nfunc (r *pgRepository) GetRankedByUsage(serverID uuid.UUID, daysLookBack int) ([]suppliers.Supplier, error) {\n\tvar result []suppliers.Supplier\n\n\tdateThreshold := time.Now().AddDate(0, 0, -daysLookBack)\n\n\t// SQL: Join Suppliers с Invoices, Group By Supplier, Order By Count DESC\n\t// Учитываем только активных поставщиков и накладные этого же сервера (через supplier_id + default_store_id косвенно,\n\t// но лучше явно фильтровать suppliers по rms_server_id).\n\t// *Примечание:* Invoices пока не имеют поля rms_server_id явно в старой схеме,\n\t// но мы должны фильтровать Suppliers по serverID.\n\n\terr := r.db.Table(\"suppliers\").\n\t\tSelect(\"suppliers.*, COUNT(invoices.id) as usage_count\").\n\t\tJoins(\"LEFT JOIN invoices ON invoices.supplier_id = suppliers.id AND invoices.date_incoming >= ?\", dateThreshold).\n\t\tWhere(\"suppliers.rms_server_id = ? AND suppliers.is_deleted = ?\", serverID, false).\n\t\tGroup(\"suppliers.id\").\n\t\tOrder(\"usage_count DESC, suppliers.name ASC\").\n\t\tFind(&result).Error\n\n\treturn result, err\n}\n\nfunc (r *pgRepository) GetByID(id uuid.UUID) (*suppliers.Supplier, error) {\n\tvar supplier suppliers.Supplier\n\terr := r.db.Where(\"id = ? AND is_deleted = ?\", id, false).First(&supplier).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &supplier, nil\n}\n\nfunc (r *pgRepository) Count(serverID uuid.UUID) (int64, error) {\n\tvar count int64\n\terr := r.db.Model(&suppliers.Supplier{}).\n\t\tWhere(\"rms_server_id = ? AND is_deleted = ?\", serverID, false).\n\t\tCount(&count).Error\n\treturn count, err\n}\n", + "internal/infrastructure/rms/client.go": "package rms\n\nimport (\n\t\"bytes\"\n\t\"crypto/sha1\"\n\t\"encoding/json\"\n\t\"encoding/xml\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/shopspring/decimal\"\n\t\"go.uber.org/zap\"\n\n\t\"rmser/internal/domain/catalog\"\n\t\"rmser/internal/domain/invoices\"\n\t\"rmser/internal/domain/recipes\"\n\t\"rmser/internal/domain/suppliers\"\n\t\"rmser/pkg/logger\"\n)\n\nconst (\n\ttokenTTL = 45 * time.Minute // Время жизни токена до принудительного обновления\n)\n\n// ClientI интерфейс\ntype ClientI interface {\n\tAuth() error\n\tLogout() error\n\tFetchCatalog() ([]catalog.Product, error)\n\tFetchStores() ([]catalog.Store, error)\n\tFetchSuppliers() ([]suppliers.Supplier, error)\n\tFetchMeasureUnits() ([]catalog.MeasureUnit, error)\n\tFetchRecipes(dateFrom, dateTo time.Time) ([]recipes.Recipe, error)\n\tFetchInvoices(from, to time.Time) ([]invoices.Invoice, error)\n\tFetchStoreOperations(presetID string, from, to time.Time) ([]StoreReportItemXML, error)\n\tCreateIncomingInvoice(inv invoices.Invoice) (string, error)\n\tGetProductByID(id uuid.UUID) (*ProductFullDTO, error)\n\tUpdateProduct(product ProductFullDTO) (*ProductFullDTO, error)\n}\n\ntype Client struct {\n\tbaseURL string\n\tlogin string\n\tpasswordHash string\n\thttpClient *http.Client\n\n\t// Защита токена для конкурентного доступа\n\tmu sync.RWMutex\n\ttoken string\n\ttokenCreatedAt time.Time\n}\n\nfunc NewClient(baseURL, login, password string) *Client {\n\th := sha1.New()\n\th.Write([]byte(password))\n\tpassHash := fmt.Sprintf(\"%x\", h.Sum(nil))\n\n\treturn &Client{\n\t\tbaseURL: baseURL,\n\t\tlogin: login,\n\t\tpasswordHash: passHash,\n\t\thttpClient: &http.Client{Timeout: 60 * time.Second},\n\t}\n}\n\n// Auth выполняет вход\nfunc (c *Client) Auth() error {\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\n\treturn c.authUnsafe()\n}\n\n// authUnsafe - внутренняя логика авторизации без блокировок (вызывается внутри Lock)\nfunc (c *Client) authUnsafe() error {\n\tendpoint := c.baseURL + \"/resto/api/auth\"\n\n\tdata := url.Values{}\n\tdata.Set(\"login\", c.login)\n\tdata.Set(\"pass\", c.passwordHash)\n\n\treq, err := http.NewRequest(\"POST\", endpoint, strings.NewReader(data.Encode()))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"ошибка создания запроса auth: %w\", err)\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/x-www-form-urlencoded\")\n\n\tresp, err := c.httpClient.Do(req)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"ошибка сети auth: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn fmt.Errorf(\"ошибка авторизации (code %d): %s\", resp.StatusCode, string(body))\n\t}\n\n\tc.token = string(body)\n\tc.tokenCreatedAt = time.Now() // Запоминаем время получения\n\tlogger.Log.Info(\"RMS: Успешная авторизация\", zap.String(\"token_preview\", c.token[:5]+\"...\"))\n\treturn nil\n}\n\n// Logout освобождает лицензию\nfunc (c *Client) Logout() error {\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\n\treturn c.logoutUnsafe()\n}\n\n// logoutUnsafe - внутренняя логика логаута\nfunc (c *Client) logoutUnsafe() error {\n\tif c.token == \"\" {\n\t\treturn nil\n\t}\n\n\tendpoint := c.baseURL + \"/resto/api/logout\"\n\tdata := url.Values{}\n\tdata.Set(\"key\", c.token)\n\n\treq, err := http.NewRequest(\"POST\", endpoint, strings.NewReader(data.Encode()))\n\tif err == nil {\n\t\treq.Header.Set(\"Content-Type\", \"application/x-www-form-urlencoded\")\n\t\tresp, err := c.httpClient.Do(req)\n\t\tif err == nil {\n\t\t\tdefer resp.Body.Close()\n\t\t\tif resp.StatusCode == http.StatusOK {\n\t\t\t\tlogger.Log.Info(\"RMS: Токен освобожден\")\n\t\t\t} else {\n\t\t\t\tlogger.Log.Warn(\"RMS: Ошибка освобождения токена\", zap.Int(\"code\", resp.StatusCode))\n\t\t\t}\n\t\t}\n\t}\n\n\t// Сбрасываем токен в любом случае, даже если запрос не прошел (он все равно протухнет)\n\tc.token = \"\"\n\tc.tokenCreatedAt = time.Time{}\n\treturn nil\n}\n\n// ensureToken проверяет срок жизни токена и обновляет его при необходимости\nfunc (c *Client) ensureToken() error {\n\tc.mu.RLock()\n\ttoken := c.token\n\tcreatedAt := c.tokenCreatedAt\n\tc.mu.RUnlock()\n\n\t// Если токена нет или он протух\n\tif token == \"\" || time.Since(createdAt) > tokenTTL {\n\t\tc.mu.Lock()\n\t\tdefer c.mu.Unlock()\n\n\t\t// Double check locking (вдруг другая горутина уже обновила)\n\t\tif c.token != \"\" && time.Since(c.tokenCreatedAt) <= tokenTTL {\n\t\t\treturn nil\n\t\t}\n\n\t\tif c.token != \"\" {\n\t\t\tlogger.Log.Info(\"RMS: Время жизни токена истекло (>45 мин), пересоздание...\")\n\t\t\t_ = c.logoutUnsafe() // Пытаемся освободить старый\n\t\t}\n\n\t\treturn c.authUnsafe()\n\t}\n\treturn nil\n}\n\n// doRequest выполняет запрос с автоматическим управлением токеном\nfunc (c *Client) doRequest(method, path string, queryParams map[string]string) (*http.Response, error) {\n\t// 1. Проверка времени жизни (45 минут)\n\tif err := c.ensureToken(); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Читаем токен под RLock\n\tc.mu.RLock()\n\tcurrentToken := c.token\n\tc.mu.RUnlock()\n\n\tbuildURL := func() string {\n\t\tu, _ := url.Parse(c.baseURL + path)\n\t\tq := u.Query()\n\t\tq.Set(\"key\", currentToken)\n\t\tfor k, v := range queryParams {\n\t\t\tq.Set(k, v)\n\t\t}\n\t\tu.RawQuery = q.Encode()\n\t\treturn u.String()\n\t}\n\n\treq, _ := http.NewRequest(method, buildURL(), nil)\n\tresp, err := c.httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// 2. Реактивная обработка 401 (если сервер перезагрузился или убил сессию раньше времени)\n\tif resp.StatusCode == http.StatusUnauthorized {\n\t\tresp.Body.Close()\n\t\tlogger.Log.Warn(\"RMS: Получен 401 Unauthorized, принудительная ре-авторизация...\")\n\n\t\tc.mu.Lock()\n\t\t// Сбрасываем токен и логинимся заново\n\t\tc.token = \"\"\n\t\tauthErr := c.authUnsafe()\n\t\tc.mu.Unlock()\n\n\t\tif authErr != nil {\n\t\t\treturn nil, authErr\n\t\t}\n\n\t\t// Повторяем запрос с новым токеном\n\t\tc.mu.RLock()\n\t\tcurrentToken = c.token\n\t\tc.mu.RUnlock()\n\n\t\treq, _ = http.NewRequest(method, buildURL(), nil)\n\t\treturn c.httpClient.Do(req)\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tdefer resp.Body.Close()\n\t\tbody, _ := io.ReadAll(resp.Body)\n\t\treturn nil, fmt.Errorf(\"api error: code=%d, body=%s\", resp.StatusCode, string(body))\n\t}\n\n\treturn resp, nil\n}\n\n// --- Методы получения данных (без изменений логики парсинга) ---\n\nfunc (c *Client) FetchCatalog() ([]catalog.Product, error) {\n\tvar products []catalog.Product\n\n\t// Группы\n\trespGroups, err := c.doRequest(\"GET\", \"/resto/api/v2/entities/products/group/list\", map[string]string{\n\t\t\"includeDeleted\": \"true\",\n\t})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"get groups error: %w\", err)\n\t}\n\tdefer respGroups.Body.Close()\n\n\tvar groupDTOs []GroupDTO\n\tif err := json.NewDecoder(respGroups.Body).Decode(&groupDTOs); err != nil {\n\t\treturn nil, fmt.Errorf(\"json decode groups error: %w\", err)\n\t}\n\n\t// Товары\n\trespProds, err := c.doRequest(\"GET\", \"/resto/api/v2/entities/products/list\", map[string]string{\n\t\t\"includeDeleted\": \"true\",\n\t})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"get products error: %w\", err)\n\t}\n\tdefer respProds.Body.Close()\n\n\tvar prodDTOs []ProductDTO\n\tif err := json.NewDecoder(respProds.Body).Decode(&prodDTOs); err != nil {\n\t\treturn nil, fmt.Errorf(\"json decode products error: %w\", err)\n\t}\n\n\t// Маппинг групп\n\tfor _, g := range groupDTOs {\n\t\tid, _ := uuid.Parse(g.ID)\n\t\tvar parentID *uuid.UUID\n\t\tif g.ParentID != nil {\n\t\t\tif pid, err := uuid.Parse(*g.ParentID); err == nil {\n\t\t\t\tparentID = &pid\n\t\t\t}\n\t\t}\n\t\tproducts = append(products, catalog.Product{\n\t\t\tID: id,\n\t\t\tParentID: parentID,\n\t\t\tName: g.Name,\n\t\t\tNum: g.Num,\n\t\t\tCode: g.Code,\n\t\t\tType: \"GROUP\",\n\t\t\tIsDeleted: g.Deleted,\n\t\t})\n\t}\n\n\t// Маппинг товаров\n\tfor _, p := range prodDTOs {\n\t\tid, _ := uuid.Parse(p.ID)\n\t\tvar parentID *uuid.UUID\n\t\tif p.ParentID != nil {\n\t\t\tif pid, err := uuid.Parse(*p.ParentID); err == nil {\n\t\t\t\tparentID = &pid\n\t\t\t}\n\t\t}\n\n\t\t// Обработка MainUnit\n\t\tvar mainUnitID *uuid.UUID\n\t\tif p.MainUnit != nil {\n\t\t\tif uid, err := uuid.Parse(*p.MainUnit); err == nil {\n\t\t\t\tmainUnitID = &uid\n\t\t\t}\n\t\t}\n\n\t\t// Маппинг фасовок\n\t\tvar containers []catalog.ProductContainer\n\t\tfor _, contDto := range p.Containers {\n\t\t\tcID, err := uuid.Parse(contDto.ID)\n\t\t\tif err == nil {\n\t\t\t\tcontainers = append(containers, catalog.ProductContainer{\n\t\t\t\t\tID: cID,\n\t\t\t\t\tProductID: id,\n\t\t\t\t\tName: contDto.Name,\n\t\t\t\t\tCount: decimal.NewFromFloat(contDto.Count),\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\n\t\tproducts = append(products, catalog.Product{\n\t\t\tID: id,\n\t\t\tParentID: parentID,\n\t\t\tName: p.Name,\n\t\t\tNum: p.Num,\n\t\t\tCode: p.Code,\n\t\t\tType: p.Type,\n\t\t\tUnitWeight: decimal.NewFromFloat(p.UnitWeight),\n\t\t\tUnitCapacity: decimal.NewFromFloat(p.UnitCapacity),\n\t\t\tMainUnitID: mainUnitID,\n\t\t\tContainers: containers,\n\t\t\tIsDeleted: p.Deleted,\n\t\t})\n\t}\n\n\treturn products, nil\n}\n\n// FetchStores загружает список складов (Account -> INVENTORY_ASSETS)\nfunc (c *Client) FetchStores() ([]catalog.Store, error) {\n\tresp, err := c.doRequest(\"GET\", \"/resto/api/v2/entities/list\", map[string]string{\n\t\t\"rootType\": \"Account\",\n\t\t\"includeDeleted\": \"false\",\n\t})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"get stores error: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tvar dtos []AccountDTO\n\tif err := json.NewDecoder(resp.Body).Decode(&dtos); err != nil {\n\t\treturn nil, fmt.Errorf(\"json decode stores error: %w\", err)\n\t}\n\n\tvar stores []catalog.Store\n\tfor _, d := range dtos {\n\t\t// Фильтруем только склады\n\t\tif d.Type != \"INVENTORY_ASSETS\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tid, err := uuid.Parse(d.ID)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar parentCorpID uuid.UUID\n\t\tif d.ParentCorporateID != nil {\n\t\t\tif parsed, err := uuid.Parse(*d.ParentCorporateID); err == nil {\n\t\t\t\tparentCorpID = parsed\n\t\t\t}\n\t\t}\n\n\t\tstores = append(stores, catalog.Store{\n\t\t\tID: id,\n\t\t\tName: d.Name,\n\t\t\tParentCorporateID: parentCorpID,\n\t\t\tIsDeleted: d.Deleted,\n\t\t})\n\t}\n\n\treturn stores, nil\n}\n\n// FetchMeasureUnits загружает справочник единиц измерения\nfunc (c *Client) FetchMeasureUnits() ([]catalog.MeasureUnit, error) {\n\t// rootType=MeasureUnit согласно документации iiko\n\tresp, err := c.doRequest(\"GET\", \"/resto/api/v2/entities/list\", map[string]string{\n\t\t\"rootType\": \"MeasureUnit\",\n\t\t\"includeDeleted\": \"false\",\n\t})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"get measure units error: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tvar dtos []GenericEntityDTO\n\tif err := json.NewDecoder(resp.Body).Decode(&dtos); err != nil {\n\t\treturn nil, fmt.Errorf(\"json decode error: %w\", err)\n\t}\n\n\tvar result []catalog.MeasureUnit\n\tfor _, d := range dtos {\n\t\tid, err := uuid.Parse(d.ID)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tresult = append(result, catalog.MeasureUnit{\n\t\t\tID: id,\n\t\t\tName: d.Name,\n\t\t\tCode: d.Code,\n\t\t})\n\t}\n\treturn result, nil\n}\n\nfunc (c *Client) FetchRecipes(dateFrom, dateTo time.Time) ([]recipes.Recipe, error) {\n\tparams := map[string]string{\n\t\t\"dateFrom\": dateFrom.Format(\"2006-01-02\"),\n\t}\n\tif !dateTo.IsZero() {\n\t\tparams[\"dateTo\"] = dateTo.Format(\"2006-01-02\")\n\t}\n\n\tresp, err := c.doRequest(\"GET\", \"/resto/api/v2/assemblyCharts/getAll\", params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"get recipes error: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tvar apiResp AssemblyChartsResponse\n\tif err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"json decode recipes error: %w\", err)\n\t}\n\n\tvar allrecipes []recipes.Recipe\n\tfor _, chart := range apiResp.AssemblyCharts {\n\t\trID, _ := uuid.Parse(chart.ID)\n\t\tpID, _ := uuid.Parse(chart.AssembledProductID)\n\n\t\tdf, _ := time.Parse(\"2006-01-02\", chart.DateFrom)\n\t\tvar dt *time.Time\n\t\tif chart.DateTo != nil {\n\t\t\tif t, err := time.Parse(\"2006-01-02\", *chart.DateTo); err == nil {\n\t\t\t\tdt = &t\n\t\t\t}\n\t\t}\n\n\t\tvar items []recipes.RecipeItem\n\t\tfor _, item := range chart.Items {\n\t\t\tiPID, _ := uuid.Parse(item.ProductID)\n\n\t\t\t// FIX: Генерируем уникальный ID для каждой строки в нашей БД,\n\t\t\t// чтобы избежать конфликтов PK при переиспользовании строк в iiko.\n\t\t\titems = append(items, recipes.RecipeItem{\n\t\t\t\tID: uuid.New(),\n\t\t\t\tRecipeID: rID,\n\t\t\t\tProductID: iPID,\n\t\t\t\tAmountIn: decimal.NewFromFloat(item.AmountIn),\n\t\t\t\tAmountOut: decimal.NewFromFloat(item.AmountOut),\n\t\t\t})\n\t\t}\n\n\t\tallrecipes = append(allrecipes, recipes.Recipe{\n\t\t\tID: rID,\n\t\t\tProductID: pID,\n\t\t\tDateFrom: df,\n\t\t\tDateTo: dt,\n\t\t\tItems: items,\n\t\t})\n\t}\n\n\treturn allrecipes, nil\n}\n\nfunc (c *Client) FetchInvoices(from, to time.Time) ([]invoices.Invoice, error) {\n\tparams := map[string]string{\n\t\t\"from\": from.Format(\"2006-01-02\"),\n\t\t\"to\": to.Format(\"2006-01-02\"),\n\t\t\"currentYear\": \"false\",\n\t}\n\n\tresp, err := c.doRequest(\"GET\", \"/resto/api/documents/export/incomingInvoice\", params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"get invoices error: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tvar xmlData IncomingInvoiceListXML\n\tif err := xml.NewDecoder(resp.Body).Decode(&xmlData); err != nil {\n\t\treturn nil, fmt.Errorf(\"xml decode invoices error: %w\", err)\n\t}\n\n\tvar allinvoices []invoices.Invoice\n\tfor _, doc := range xmlData.Documents {\n\t\tdocID, _ := uuid.Parse(doc.ID)\n\t\tsupID, _ := uuid.Parse(doc.Supplier)\n\t\tstoreID, _ := uuid.Parse(doc.DefaultStore)\n\t\tdateInc, _ := time.Parse(\"2006-01-02T15:04:05\", doc.DateIncoming)\n\n\t\tvar items []invoices.InvoiceItem\n\t\tfor _, it := range doc.Items {\n\t\t\tpID, _ := uuid.Parse(it.Product)\n\t\t\titems = append(items, invoices.InvoiceItem{\n\t\t\t\tInvoiceID: docID,\n\t\t\t\tProductID: pID,\n\t\t\t\tAmount: decimal.NewFromFloat(it.Amount),\n\t\t\t\tPrice: decimal.NewFromFloat(it.Price),\n\t\t\t\tSum: decimal.NewFromFloat(it.Sum),\n\t\t\t\tVatSum: decimal.NewFromFloat(it.VatSum),\n\t\t\t})\n\t\t}\n\n\t\tallinvoices = append(allinvoices, invoices.Invoice{\n\t\t\tID: docID,\n\t\t\tDocumentNumber: doc.DocumentNumber,\n\t\t\tIncomingDocumentNumber: doc.IncomingDocumentNumber,\n\t\t\tDateIncoming: dateInc,\n\t\t\tSupplierID: supID,\n\t\t\tDefaultStoreID: storeID,\n\t\t\tStatus: doc.Status,\n\t\t\tItems: items,\n\t\t})\n\t}\n\n\treturn allinvoices, nil\n}\n\n// FetchStoreOperations загружает складской отчет по ID пресета\nfunc (c *Client) FetchStoreOperations(presetID string, from, to time.Time) ([]StoreReportItemXML, error) {\n\tparams := map[string]string{\n\t\t\"presetId\": presetID,\n\t\t\"dateFrom\": from.Format(\"02.01.2006\"), // В документации формат DD.MM.YYYY\n\t\t\"dateTo\": to.Format(\"02.01.2006\"),\n\t}\n\n\tresp, err := c.doRequest(\"GET\", \"/resto/api/reports/storeOperations\", params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"fetch store operations error: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tvar report StoreReportResponse\n\tif err := xml.NewDecoder(resp.Body).Decode(&report); err != nil {\n\t\t// Иногда RMS возвращает пустой ответ или ошибку текстом при отсутствии данных\n\t\treturn nil, fmt.Errorf(\"xml decode store operations error: %w\", err)\n\t}\n\n\treturn report.Items, nil\n}\n\n// CreateIncomingInvoice отправляет накладную в iiko\nfunc (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) {\n\t// 1. Маппинг Domain -> XML DTO\n\n\t// Статус по умолчанию NEW, если не передан\n\tstatus := inv.Status\n\tif status == \"\" {\n\t\tstatus = \"NEW\"\n\t}\n\n\t// Комментарий по умолчанию, если пустой\n\tcomment := inv.Comment\n\tif comment == \"\" {\n\t\tcomment = \"Loaded via RMSER OCR\"\n\t}\n\n\treqDTO := IncomingInvoiceImportXML{\n\t\tDocumentNumber: inv.DocumentNumber,\n\t\tIncomingDocumentNumber: inv.IncomingDocumentNumber, // Присваиваем входящий номер документа из домена\n\t\tDateIncoming: inv.DateIncoming.Format(\"02.01.2006\"),\n\t\tDefaultStore: inv.DefaultStoreID.String(),\n\t\tSupplier: inv.SupplierID.String(),\n\t\tStatus: status,\n\t\tComment: comment,\n\t}\n\n\tif inv.ID != uuid.Nil {\n\t\treqDTO.ID = inv.ID.String()\n\t}\n\n\tfor i, item := range inv.Items {\n\t\tamount, _ := item.Amount.Float64()\n\t\tprice, _ := item.Price.Float64()\n\t\tsum, _ := item.Sum.Float64()\n\n\t\txmlItem := IncomingInvoiceImportItemXML{\n\t\t\tProductID: item.ProductID.String(),\n\t\t\tAmount: amount,\n\t\t\tPrice: price,\n\t\t\tSum: sum,\n\t\t\tNum: i + 1,\n\t\t\tStore: inv.DefaultStoreID.String(),\n\t\t}\n\n\t\tif item.ContainerID != nil && *item.ContainerID != uuid.Nil {\n\t\t\txmlItem.ContainerId = item.ContainerID.String()\n\t\t}\n\n\t\treqDTO.ItemsWrapper.Items = append(reqDTO.ItemsWrapper.Items, xmlItem)\n\t}\n\n\t// 2. Маршалинг в XML\n\txmlBytes, err := xml.Marshal(reqDTO)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"xml marshal error: %w\", err)\n\t}\n\t// Добавляем XML header вручную\n\txmlPayload := []byte(xml.Header + string(xmlBytes))\n\n\t// 3. Получение токена\n\tif err := c.ensureToken(); err != nil {\n\t\treturn \"\", err\n\t}\n\n\tc.mu.RLock()\n\ttoken := c.token\n\tc.mu.RUnlock()\n\n\t// 4. Формирование URL\n\tendpoint, _ := url.Parse(c.baseURL + \"/resto/api/documents/import/incomingInvoice\")\n\tq := endpoint.Query()\n\tq.Set(\"key\", token)\n\tendpoint.RawQuery = q.Encode()\n\n\tfullURL := endpoint.String()\n\n\t// --- ЛОГИРОВАНИЕ ЗАПРОСА (URL + BODY) ---\n\t// Логируем как Info, чтобы точно увидеть в консоли при отладке\n\tlogger.Log.Info(\"RMS POST Request Debug\",\n\t\tzap.String(\"method\", \"POST\"),\n\t\tzap.String(\"url\", fullURL),\n\t\tzap.String(\"body_payload\", string(xmlPayload)),\n\t)\n\n\t// 5. Отправка\n\treq, err := http.NewRequest(\"POST\", fullURL, bytes.NewReader(xmlPayload))\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/xml\")\n\n\tresp, err := c.httpClient.Do(req)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"network error: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\t// Читаем ответ\n\trespBody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// Логируем ответ для симметрии\n\tlogger.Log.Info(\"RMS POST Response Debug\",\n\t\tzap.Int(\"status_code\", resp.StatusCode),\n\t\tzap.String(\"response_body\", string(respBody)),\n\t)\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn \"\", fmt.Errorf(\"http error %d: %s\", resp.StatusCode, string(respBody))\n\t}\n\n\tvar result DocumentValidationResult\n\tif err := xml.Unmarshal(respBody, &result); err != nil {\n\t\treturn \"\", fmt.Errorf(\"xml response unmarshal error: %w\", err)\n\t}\n\n\tif !result.Valid {\n\t\tlogger.Log.Warn(\"RMS Invoice Import Failed\",\n\t\t\tzap.String(\"error\", result.ErrorMessage),\n\t\t\tzap.String(\"additional\", result.AdditionalInfo),\n\t\t)\n\t\treturn \"\", fmt.Errorf(\"iiko validation failed: %s (info: %s)\", result.ErrorMessage, result.AdditionalInfo)\n\t}\n\n\treturn result.DocumentNumber, nil\n}\n\n// GetProductByID получает полную структуру товара по ID (через /list?ids=...)\nfunc (c *Client) GetProductByID(id uuid.UUID) (*ProductFullDTO, error) {\n\t// Параметр ids должен быть списком. iiko ожидает ids=UUID\n\tparams := map[string]string{\n\t\t\"ids\": id.String(),\n\t\t\"includeDeleted\": \"false\",\n\t}\n\n\tresp, err := c.doRequest(\"GET\", \"/resto/api/v2/entities/products/list\", params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"request error: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"rms error code %d\", resp.StatusCode)\n\t}\n\n\t// Ответ - это массив товаров\n\tvar products []ProductFullDTO\n\tif err := json.NewDecoder(resp.Body).Decode(&products); err != nil {\n\t\treturn nil, fmt.Errorf(\"json decode error: %w\", err)\n\t}\n\n\tif len(products) == 0 {\n\t\treturn nil, fmt.Errorf(\"product not found in rms\")\n\t}\n\n\treturn &products[0], nil\n}\n\n// UpdateProduct отправляет полную структуру товара на обновление (/update)\nfunc (c *Client) UpdateProduct(product ProductFullDTO) (*ProductFullDTO, error) {\n\t// Маршалим тело\n\tbodyBytes, err := json.Marshal(product)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"json marshal error: %w\", err)\n\t}\n\n\t// Используем doRequestPost (надо реализовать или вручную, т.к. doRequest у нас GET-ориентирован в текущем коде был прост)\n\t// Расширим логику doRequest или напишем тут, т.к. это POST с JSON body\n\tif err := c.ensureToken(); err != nil {\n\t\treturn nil, err\n\t}\n\tc.mu.RLock()\n\ttoken := c.token\n\tc.mu.RUnlock()\n\n\tendpoint := c.baseURL + \"/resto/api/v2/entities/products/update?key=\" + token\n\n\treq, err := http.NewRequest(\"POST\", endpoint, bytes.NewReader(bodyBytes))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\tresp, err := c.httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\trespBody, _ := io.ReadAll(resp.Body)\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"update failed (code %d): %s\", resp.StatusCode, string(respBody))\n\t}\n\n\tvar result UpdateEntityResponse\n\tif err := json.Unmarshal(respBody, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"response unmarshal error: %w\", err)\n\t}\n\n\tif result.Result != \"SUCCESS\" {\n\t\t// Собираем ошибки\n\t\terrMsg := \"rms update error: \"\n\t\tfor _, e := range result.Errors {\n\t\t\terrMsg += fmt.Sprintf(\"[%s] %s; \", e.Code, e.Value)\n\t\t}\n\t\treturn nil, fmt.Errorf(errMsg)\n\t}\n\n\tif result.Response == nil {\n\t\treturn nil, fmt.Errorf(\"empty response from rms after update\")\n\t}\n\n\treturn result.Response, nil\n}\n\n// GetServerInfo пытается получить информацию о сервере (имя, версия) без авторизации.\n// Использует endpoint /resto/getServerMonitoringInfo.jsp\nfunc GetServerInfo(baseURL string) (*ServerMonitoringInfoDTO, error) {\n\t// Формируем URL. Убираем слэш в конце, если есть.\n\turl := strings.TrimRight(baseURL, \"/\") + \"/resto/getServerMonitoringInfo.jsp\"\n\n\tlogger.Log.Info(\"RMS: Requesting server info\", zap.String(\"url\", url))\n\n\tclient := &http.Client{Timeout: 5 * time.Second}\n\tresp, err := client.Get(url)\n\tif err != nil {\n\t\tlogger.Log.Error(\"RMS: Monitoring connection failed\", zap.Error(err))\n\t\treturn nil, fmt.Errorf(\"connection error: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbodyBytes, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"read body error: %w\", err)\n\t}\n\n\tlogger.Log.Info(\"RMS: Monitoring Response\",\n\t\tzap.Int(\"status\", resp.StatusCode),\n\t\tzap.String(\"body\", string(bodyBytes)))\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"status code %d\", resp.StatusCode)\n\t}\n\n\tvar info ServerMonitoringInfoDTO\n\n\t// Пробуем JSON (так как в логе пришел JSON)\n\tif err := json.Unmarshal(bodyBytes, &info); err != nil {\n\t\t// Если вдруг JSON не прошел, можно попробовать XML как фоллбек (для старых версий)\n\t\tlogger.Log.Warn(\"RMS: JSON decode failed, trying XML...\", zap.Error(err))\n\t\tif xmlErr := xml.Unmarshal(bodyBytes, &info); xmlErr != nil {\n\t\t\treturn nil, fmt.Errorf(\"decode error (json & xml failed): %w\", err)\n\t\t}\n\t}\n\n\treturn &info, nil\n}\n\n// FetchSuppliers загружает список поставщиков через XML API\nfunc (c *Client) FetchSuppliers() ([]suppliers.Supplier, error) {\n\t// Endpoint /resto/api/suppliers\n\tresp, err := c.doRequest(\"GET\", \"/resto/api/suppliers\", nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"get suppliers error: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tvar xmlData SuppliersListXML\n\tif err := xml.NewDecoder(resp.Body).Decode(&xmlData); err != nil {\n\t\treturn nil, fmt.Errorf(\"xml decode suppliers error: %w\", err)\n\t}\n\n\tvar result []suppliers.Supplier\n\tfor _, emp := range xmlData.Employees {\n\t\tid, err := uuid.Parse(emp.ID)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tisDeleted := emp.Deleted == \"true\"\n\n\t\tresult = append(result, suppliers.Supplier{\n\t\t\tID: id,\n\t\t\tName: emp.Name,\n\t\t\tCode: emp.Code,\n\t\t\tINN: emp.TaxpayerIdNumber,\n\t\t\tIsDeleted: isDeleted,\n\t\t\t// RMSServerID проставляется в сервисе перед сохранением\n\t\t})\n\t}\n\n\treturn result, nil\n}\n", + "internal/infrastructure/rms/dto.go": "package rms\n\nimport (\n\t\"encoding/xml\"\n)\n\n// --- JSON DTOs (V2 API) ---\n\ntype ProductDTO struct {\n\tID string `json:\"id\"`\n\tParentID *string `json:\"parent\"` // Может быть null\n\tName string `json:\"name\"`\n\tNum string `json:\"num\"` // Артикул\n\tCode string `json:\"code\"` // Код быстрого набора\n\tType string `json:\"type\"` // GOODS, DISH, PREPARED, etc.\n\tUnitWeight float64 `json:\"unitWeight\"`\n\tUnitCapacity float64 `json:\"unitCapacity\"`\n\tMainUnit *string `json:\"mainUnit\"`\n\tContainers []ContainerDTO `json:\"containers\"`\n\tDeleted bool `json:\"deleted\"`\n}\n\n// GenericEntityDTO используется для простых справочников (MeasureUnit и др.)\ntype GenericEntityDTO struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tCode string `json:\"code\"`\n\tDeleted bool `json:\"deleted\"`\n}\n\n// AccountDTO используется для парсинга складов (INVENTORY_ASSETS)\ntype AccountDTO struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tType string `json:\"type\"` // Нас интересует \"INVENTORY_ASSETS\"\n\tParentCorporateID *string `json:\"parentCorporateId\"`\n\tDeleted bool `json:\"deleted\"`\n}\n\n// ContainerDTO - фасовка из iiko\ntype ContainerDTO struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"` // Название фасовки (напр. \"Коробка\")\n\tCount float64 `json:\"count\"` // Сколько базовых единиц в фасовке\n}\n\ntype GroupDTO struct {\n\tID string `json:\"id\"`\n\tParentID *string `json:\"parent\"`\n\tName string `json:\"name\"`\n\tNum string `json:\"num\"`\n\tCode string `json:\"code\"`\n\tDescription string `json:\"description\"`\n\tDeleted bool `json:\"deleted\"`\n}\n\ntype AssemblyChartsResponse struct {\n\tAssemblyCharts []AssemblyChartDTO `json:\"assemblyCharts\"`\n\t// preparedCharts и другие поля пока опускаем, если не нужны для базового импорта\n}\n\ntype AssemblyChartDTO struct {\n\tID string `json:\"id\"`\n\tAssembledProductID string `json:\"assembledProductId\"`\n\tDateFrom string `json:\"dateFrom\"` // Format: \"2018-01-29\" (yyyy-MM-dd)\n\tDateTo *string `json:\"dateTo\"` // Nullable\n\tItems []AssemblyItemDTO `json:\"items\"`\n}\n\ntype AssemblyItemDTO struct {\n\tID string `json:\"id\"` // Добавили поле ID строки техкарты\n\tProductID string `json:\"productId\"`\n\tAmountIn float64 `json:\"amountIn\"`\n\tAmountOut float64 `json:\"amountOut\"`\n}\n\n// ProductFullDTO используется для получения (list?ids=...) и обновления (update) товара целиком.\ntype ProductFullDTO struct {\n\tID string `json:\"id\"`\n\tDeleted bool `json:\"deleted\"`\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tNum string `json:\"num\"`\n\tCode string `json:\"code\"`\n\tParent *string `json:\"parent\"` // null или UUID\n\tModifiers []interface{} `json:\"modifiers\"` // Оставляем interface{}, чтобы не мапить сложную структуру, если не меняем её\n\tTaxCategory *string `json:\"taxCategory\"`\n\tCategory *string `json:\"category\"`\n\tAccountingCategory *string `json:\"accountingCategory\"`\n\tColor map[string]int `json:\"color\"`\n\tFontColor map[string]int `json:\"fontColor\"`\n\tFrontImageID *string `json:\"frontImageId\"`\n\tPosition *int `json:\"position\"`\n\tModifierSchemaID *string `json:\"modifierSchemaId\"`\n\tMainUnit string `json:\"mainUnit\"` // Обязательное поле\n\tExcludedSections []string `json:\"excludedSections\"` // Set\n\tDefaultSalePrice float64 `json:\"defaultSalePrice\"`\n\tPlaceType *string `json:\"placeType\"`\n\tDefaultIncInMenu bool `json:\"defaultIncludedInMenu\"`\n\tType string `json:\"type\"` // GOODS, DISH...\n\tUnitWeight float64 `json:\"unitWeight\"`\n\tUnitCapacity float64 `json:\"unitCapacity\"`\n\tStoreBalanceLevels []StoreBalanceLevel `json:\"storeBalanceLevels\"`\n\tUseBalanceForSell bool `json:\"useBalanceForSell\"`\n\tContainers []ContainerFullDTO `json:\"containers\"`\n\tProductScaleID *string `json:\"productScaleId\"`\n\tBarcodes []interface{} `json:\"barcodes\"`\n\tColdLossPercent float64 `json:\"coldLossPercent\"`\n\tHotLossPercent float64 `json:\"hotLossPercent\"`\n\tOuterCode *string `json:\"outerEconomicActivityNomenclatureCode\"`\n\tAllergenGroups []interface{} `json:\"allergenGroups\"`\n\tEstPurchasePrice float64 `json:\"estimatedPurchasePrice\"`\n\tCanSetOpenPrice bool `json:\"canSetOpenPrice\"`\n\tNotInStoreMovement bool `json:\"notInStoreMovement\"`\n}\n\ntype StoreBalanceLevel struct {\n\tStoreID string `json:\"storeId\"`\n\tMinBalanceLevel *float64 `json:\"minBalanceLevel\"`\n\tMaxBalanceLevel *float64 `json:\"maxBalanceLevel\"`\n}\n\ntype ContainerFullDTO struct {\n\tID *string `json:\"id,omitempty\"` // При создании новой фасовки ID пустой/null\n\tNum string `json:\"num\"` // Порядковый номер? Обычно строка.\n\tName string `json:\"name\"`\n\tCount float64 `json:\"count\"`\n\tMinContainerWeight float64 `json:\"minContainerWeight\"`\n\tMaxContainerWeight float64 `json:\"maxContainerWeight\"`\n\tContainerWeight float64 `json:\"containerWeight\"`\n\tFullContainerWeight float64 `json:\"fullContainerWeight\"`\n\tBackwardRecalculation bool `json:\"backwardRecalculation\"`\n\tDeleted bool `json:\"deleted\"`\n\tUseInFront bool `json:\"useInFront\"`\n}\n\n// --- XML DTOs (Legacy API) ---\n\ntype IncomingInvoiceListXML struct {\n\tXMLName xml.Name `xml:\"incomingInvoiceDtoes\"`\n\tDocuments []IncomingInvoiceXML `xml:\"document\"`\n}\n\ntype IncomingInvoiceXML struct {\n\tID string `xml:\"id\"`\n\tDocumentNumber string `xml:\"documentNumber\"`\n\tIncomingDocumentNumber string `xml:\"incomingDocumentNumber\"`\n\tDateIncoming string `xml:\"dateIncoming\"` // Format: yyyy-MM-ddTHH:mm:ss\n\tStatus string `xml:\"status\"` // PROCESSED, NEW, DELETED\n\tSupplier string `xml:\"supplier\"` // GUID\n\tDefaultStore string `xml:\"defaultStore\"` // GUID\n\tItems []InvoiceItemXML `xml:\"items>item\"`\n}\n\ntype InvoiceItemXML struct {\n\tProduct string `xml:\"product\"` // GUID\n\tAmount float64 `xml:\"amount\"` // Количество в основных единицах\n\tPrice float64 `xml:\"price\"` // Цена за единицу\n\tSum float64 `xml:\"sum\"` // Сумма без скидки (обычно)\n\tVatSum float64 `xml:\"vatSum\"` // Сумма НДС\n}\n\n// --- XML DTOs (Store Reports) ---\n\ntype StoreReportResponse struct {\n\tXMLName xml.Name `xml:\"storeReportItemDtoes\"`\n\tItems []StoreReportItemXML `xml:\"storeReportItemDto\"`\n}\n\ntype StoreReportItemXML struct {\n\t// Основные идентификаторы\n\tProductID string `xml:\"product\"` // GUID товара\n\tProductGroup string `xml:\"productGroup\"` // GUID группы\n\tStore string `xml:\"primaryStore\"` // GUID склада\n\tDocumentID string `xml:\"documentId\"` // GUID документа\n\tDocumentNum string `xml:\"documentNum\"` // Номер документа (строка)\n\n\t// Типы (ENUMs)\n\tDocumentType string `xml:\"documentType\"` // Например: INCOMING_INVOICE\n\tTransactionType string `xml:\"type\"` // Например: INVOICE, WRITEOFF\n\n\t// Финансы и количество\n\tAmount float64 `xml:\"amount\"` // Количество\n\tSum float64 `xml:\"sum\"` // Сумма с НДС\n\tSumWithoutNds float64 `xml:\"sumWithoutNds\"` // Сумма без НДС\n\tCost float64 `xml:\"cost\"` // Себестоимость\n\n\t// Флаги и даты (используем строки для дат, так как парсинг делаем в сервисе)\n\tIncoming bool `xml:\"incoming\"`\n\tDate string `xml:\"date\"`\n\tOperationalDate string `xml:\"operationalDate\"`\n}\n\n// --- XML DTOs (Import API) ---\n\n// IncomingInvoiceImportXML описывает структуру для POST запроса импорта\ntype IncomingInvoiceImportXML struct {\n\tXMLName xml.Name `xml:\"document\"`\n\tID string `xml:\"id,omitempty\"` // GUID, если редактируем\n\tDocumentNumber string `xml:\"documentNumber,omitempty\"`\n\tIncomingDocumentNumber string `xml:\"incomingDocumentNumber,omitempty\"` // Входящий номер документа\n\tDateIncoming string `xml:\"dateIncoming,omitempty\"` // Format: dd.MM.yyyy\n\tInvoice string `xml:\"invoice,omitempty\"` // Номер счет-фактуры\n\tDefaultStore string `xml:\"defaultStore\"` // GUID склада (обязательно)\n\tSupplier string `xml:\"supplier\"` // GUID поставщика (обязательно)\n\tComment string `xml:\"comment,omitempty\"`\n\tStatus string `xml:\"status,omitempty\"` // NEW, PROCESSED\n\tItemsWrapper struct {\n\t\tItems []IncomingInvoiceImportItemXML `xml:\"item\"`\n\t} `xml:\"items\"`\n}\n\ntype IncomingInvoiceImportItemXML struct {\n\tProductID string `xml:\"product\"` // GUID товара\n\tAmount float64 `xml:\"amount\"` // Кол-во (в фасовках, если указан containerId)\n\tPrice float64 `xml:\"price\"` // Цена за единицу (за фасовку, если указан containerId)\n\tSum float64 `xml:\"sum,omitempty\"` // Сумма\n\tStore string `xml:\"store\"` // GUID склада\n\tContainerId string `xml:\"containerId,omitempty\"` // ID фасовки\n\tAmountUnit string `xml:\"amountUnit,omitempty\"` // GUID единицы измерения (можно опустить, если фасовка)\n\tNum int `xml:\"num,omitempty\"`\n}\n\n// DocumentValidationResult описывает ответ сервера при импорте\ntype DocumentValidationResult struct {\n\tXMLName xml.Name `xml:\"documentValidationResult\"`\n\tValid bool `xml:\"valid\"`\n\tWarning bool `xml:\"warning\"`\n\tDocumentNumber string `xml:\"documentNumber\"`\n\tOtherSuggestedNumber string `xml:\"otherSuggestedNumber\"`\n\tErrorMessage string `xml:\"errorMessage\"`\n\tAdditionalInfo string `xml:\"additionalInfo\"`\n}\n\n// --- Вспомогательные DTO для ответов (REST) ---\n\n// UpdateEntityResponse - ответ на /save или /update\ntype UpdateEntityResponse struct {\n\tResult string `json:\"result\"` // \"SUCCESS\" or \"ERROR\"\n\tResponse *ProductFullDTO `json:\"response\"`\n\tErrors []ErrorDTO `json:\"errors\"`\n}\n\ntype ErrorDTO struct {\n\tCode string `json:\"code\"`\n\tValue string `json:\"value\"`\n}\n\n// ServerMonitoringInfoDTO используется для парсинга ответа мониторинга\n// iiko может отдавать JSON, поэтому ставим json теги.\ntype ServerMonitoringInfoDTO struct {\n\tServerName string `json:\"serverName\" xml:\"serverName\"`\n\tVersion string `json:\"version\" xml:\"version\"`\n}\n\n// --- Suppliers XML (Legacy API /resto/api/suppliers) ---\n\ntype SuppliersListXML struct {\n\tXMLName xml.Name `xml:\"employees\"`\n\tEmployees []SupplierXML `xml:\"employee\"`\n}\n\ntype SupplierXML struct {\n\tID string `xml:\"id\"`\n\tName string `xml:\"name\"`\n\tCode string `xml:\"code\"`\n\tTaxpayerIdNumber string `xml:\"taxpayerIdNumber\"` // ИНН\n\tDeleted string `xml:\"deleted\"` // \"true\" / \"false\"\n}\n", + "internal/infrastructure/rms/factory.go": "package rms\n\nimport (\n\t\"fmt\"\n\t\"sync\"\n\n\t\"github.com/google/uuid\"\n\t\"go.uber.org/zap\"\n\n\t\"rmser/internal/domain/account\"\n\t\"rmser/pkg/crypto\"\n\t\"rmser/pkg/logger\"\n)\n\n// Factory управляет созданием и переиспользованием клиентов RMS\ntype Factory struct {\n\taccountRepo account.Repository\n\tcryptoManager *crypto.CryptoManager\n\n\t// Кэш активных клиентов: ServerID -> *Client\n\tmu sync.RWMutex\n\tclients map[uuid.UUID]*Client\n}\n\nfunc NewFactory(repo account.Repository, cm *crypto.CryptoManager) *Factory {\n\treturn &Factory{\n\t\taccountRepo: repo,\n\t\tcryptoManager: cm,\n\t\tclients: make(map[uuid.UUID]*Client),\n\t}\n}\n\n// GetClientForUser возвращает клиент для текущего активного сервера пользователя.\n// Использует личные или наследуемые (от Owner) учетные данные.\nfunc (f *Factory) GetClientForUser(userID uuid.UUID) (ClientI, error) {\n\t// 1. Пытаемся найти в кэше\n\tf.mu.RLock()\n\tclient, exists := f.clients[userID]\n\tf.mu.RUnlock()\n\n\tif exists {\n\t\treturn client, nil\n\t}\n\n\t// 2. Создаем новый клиент под блокировкой\n\tf.mu.Lock()\n\tdefer f.mu.Unlock()\n\n\t// Double check\n\tif client, exists := f.clients[userID]; exists {\n\t\treturn client, nil\n\t}\n\n\t// 3. Получаем креды из репозитория (учитывая фоллбэк на Owner'а)\n\tbaseURL, login, encryptedPass, err := f.accountRepo.GetActiveConnectionCredentials(userID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"ошибка получения настроек подключения: %w\", err)\n\t}\n\n\t// 4. Расшифровка пароля\n\tplainPass, err := f.cryptoManager.Decrypt(encryptedPass)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"ошибка расшифровки пароля RMS: %w\", err)\n\t}\n\n\t// 5. Создание клиента\n\tnewClient := NewClient(baseURL, login, plainPass)\n\tf.clients[userID] = newClient\n\n\tlogger.Log.Info(\"RMS Factory: Client created for user\",\n\t\tzap.String(\"user_id\", userID.String()),\n\t\tzap.String(\"login\", login),\n\t\tzap.String(\"url\", baseURL))\n\n\treturn newClient, nil\n}\n\n// CreateClientFromRawCredentials создает клиент без сохранения в кэш (для тестов подключения)\nfunc (f *Factory) CreateClientFromRawCredentials(url, login, password string) *Client {\n\treturn NewClient(url, login, password)\n}\n\n// ClearCacheForUser сбрасывает кэш пользователя (при смене сервера или выходе)\nfunc (f *Factory) ClearCacheForUser(userID uuid.UUID) {\n\tf.mu.Lock()\n\tdefer f.mu.Unlock()\n\tdelete(f.clients, userID)\n}\n\n// ClearCacheForServer сбрасывает кэш для ВСЕХ пользователей сервера (например, при смене пароля владельцем)\n// Это дорогая операция, но необходимая при изменении общих кредов.\nfunc (f *Factory) ClearCacheForServer(serverID uuid.UUID) {\n\t// Пока не реализовано эффективно (нужен обратный индекс).\n\t// Для MVP можно просто очистить весь кэш или оставить как есть,\n\t// так как токены iiko все равно протухнут.\n}\n", + "internal/infrastructure/yookassa/client.go": "package yookassa\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/shopspring/decimal\"\n)\n\ntype Client struct {\n\tshopID string\n\tsecretKey string\n\tbaseURL string\n\thttpClient *http.Client\n}\n\nfunc NewClient(shopID, secretKey string) *Client {\n\treturn &Client{\n\t\tshopID: shopID,\n\t\tsecretKey: secretKey,\n\t\tbaseURL: \"https://api.yookassa.ru/v3\",\n\t\thttpClient: &http.Client{Timeout: 15 * time.Second},\n\t}\n}\n\nfunc (c *Client) CreatePayment(ctx context.Context, amount decimal.Decimal, description string, orderID uuid.UUID, returnURL string) (*PaymentResponse, error) {\n\treqBody := PaymentRequest{\n\t\tAmount: Amount{\n\t\t\tValue: amount.StringFixed(2),\n\t\t\tCurrency: \"RUB\",\n\t\t},\n\t\tCapture: true, // Автоматическое списание\n\t\tConfirmation: Confirmation{\n\t\t\tType: \"redirect\",\n\t\t\tReturnURL: returnURL,\n\t\t},\n\t\tMetadata: map[string]string{\n\t\t\t\"order_id\": orderID.String(),\n\t\t},\n\t\tDescription: description,\n\t}\n\n\tbody, _ := json.Marshal(reqBody)\n\treq, err := http.NewRequestWithContext(ctx, \"POST\", c.baseURL+\"/payments\", bytes.NewReader(body))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Basic Auth\n\tauth := base64.StdEncoding.EncodeToString([]byte(c.shopID + \":\" + c.secretKey))\n\treq.Header.Set(\"Authorization\", \"Basic \"+auth)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\t// Идемпотентность: используем наш order_id как ключ\n\treq.Header.Set(\"Idempotence-Key\", orderID.String())\n\n\tresp, err := c.httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"yookassa error: status %d\", resp.StatusCode)\n\t}\n\n\tvar result PaymentResponse\n\tif err := json.NewDecoder(resp.Body).Decode(&result); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &result, nil\n}\n", + "internal/infrastructure/yookassa/dto.go": "package yookassa\n\ntype Amount struct {\n\tValue string `json:\"value\"`\n\tCurrency string `json:\"currency\"`\n}\n\ntype Confirmation struct {\n\tType string `json:\"type\"`\n\tReturnURL string `json:\"return_url,omitempty\"`\n\tURL string `json:\"confirmation_url,omitempty\"`\n}\n\ntype PaymentRequest struct {\n\tAmount Amount `json:\"amount\"`\n\tCapture bool `json:\"capture\"`\n\tConfirmation Confirmation `json:\"confirmation\"`\n\tMetadata map[string]string `json:\"metadata\"`\n\tDescription string `json:\"description\"`\n}\n\ntype PaymentResponse struct {\n\tID string `json:\"id\"`\n\tStatus string `json:\"status\"`\n\tAmount Amount `json:\"amount\"`\n\tConfirmation Confirmation `json:\"confirmation\"`\n\tMetadata map[string]string `json:\"metadata\"`\n}\n\ntype WebhookEvent struct {\n\tEvent string `json:\"event\"`\n\tType string `json:\"type\"`\n\tObject PaymentResponse `json:\"object\"`\n}\n", + "internal/services/billing/service.go": "// internal/services/billing/service.go\n\npackage billing\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"rmser/internal/domain/account\"\n\t\"rmser/internal/domain/billing\"\n\t\"rmser/internal/infrastructure/yookassa\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/shopspring/decimal\"\n)\n\n// Список доступных тарифов\nvar AvailableTariffs = []billing.Tariff{\n\t{ID: \"pack_10\", Name: \"Пакет 10\", Type: billing.TariffPack, Price: 500, InvoicesCount: 10, DurationDays: 30},\n\t{ID: \"pack_50\", Name: \"Пакет 50\", Type: billing.TariffPack, Price: 1000, InvoicesCount: 50, DurationDays: 180},\n\t{ID: \"pack_100\", Name: \"Пакет 100\", Type: billing.TariffPack, Price: 1500, InvoicesCount: 100, DurationDays: 365},\n\t{ID: \"sub_7\", Name: \"Безлимит 7 дней\", Type: billing.TariffSubscription, Price: 700, InvoicesCount: 1000, DurationDays: 7},\n\t{ID: \"sub_14\", Name: \"Безлимит 14 дней\", Type: billing.TariffSubscription, Price: 1300, InvoicesCount: 1000, DurationDays: 14},\n\t{ID: \"sub_30\", Name: \"Безлимит 30 дней\", Type: billing.TariffSubscription, Price: 2500, InvoicesCount: 1000, DurationDays: 30},\n}\n\n// PaymentNotifier определяет интерфейс для уведомления о успешном платеже\ntype PaymentNotifier interface {\n\tNotifySuccess(userID uuid.UUID, amount float64, newBalance int, serverName string)\n}\n\ntype Service struct {\n\tbillingRepo billing.Repository\n\taccountRepo account.Repository\n\tykClient *yookassa.Client\n\tnotifier PaymentNotifier\n}\n\nfunc (s *Service) SetNotifier(n PaymentNotifier) {\n\ts.notifier = n\n}\n\nfunc NewService(bRepo billing.Repository, aRepo account.Repository, yk *yookassa.Client) *Service {\n\treturn &Service{\n\t\tbillingRepo: bRepo,\n\t\taccountRepo: aRepo,\n\t\tykClient: yk,\n\t}\n}\n\nfunc (s *Service) GetTariffs() []billing.Tariff {\n\treturn AvailableTariffs\n}\n\n// CreateOrder теперь возвращает (*billing.Order, string, error)\nfunc (s *Service) CreateOrder(ctx context.Context, userID uuid.UUID, tariffID string, targetServerURL string, returnURL string) (*billing.Order, string, error) {\n\t// 1. Ищем тариф\n\tvar selectedTariff *billing.Tariff\n\tfor _, t := range AvailableTariffs {\n\t\tif t.ID == tariffID {\n\t\t\tselectedTariff = &t\n\t\t\tbreak\n\t\t}\n\t}\n\tif selectedTariff == nil {\n\t\treturn nil, \"\", errors.New(\"тариф не найден\")\n\t}\n\n\t// 2. Определяем целевой сервер\n\tvar targetServerID uuid.UUID\n\tif targetServerURL != \"\" {\n\t\tsrv, err := s.accountRepo.GetServerByURL(targetServerURL)\n\t\tif err != nil {\n\t\t\treturn nil, \"\", fmt.Errorf(\"сервер не найден: %w\", err)\n\t\t}\n\t\ttargetServerID = srv.ID\n\t} else {\n\t\tsrv, err := s.accountRepo.GetActiveServer(userID)\n\t\tif err != nil || srv == nil {\n\t\t\treturn nil, \"\", errors.New(\"активный сервер не выбран\")\n\t\t}\n\t\ttargetServerID = srv.ID\n\t}\n\n\t// 3. Создаем заказ в БД\n\torder := &billing.Order{\n\t\tID: uuid.New(),\n\t\tUserID: userID,\n\t\tTargetServerID: targetServerID,\n\t\tTariffID: tariffID,\n\t\tAmount: selectedTariff.Price,\n\t\tStatus: billing.StatusPending,\n\t}\n\n\tif err := s.billingRepo.CreateOrder(order); err != nil {\n\t\treturn nil, \"\", err\n\t}\n\n\t// 4. Запрос к ЮКассе\n\tdescription := fmt.Sprintf(\"Оплата тарифа %s\", selectedTariff.Name)\n\tykResp, err := s.ykClient.CreatePayment(ctx, decimal.NewFromFloat(order.Amount), description, order.ID, returnURL)\n\tif err != nil {\n\t\treturn nil, \"\", fmt.Errorf(\"yookassa error: %w\", err)\n\t}\n\n\t// Сохраняем payment_id от ЮКассы в наш заказ\n\tif err := s.billingRepo.UpdateOrderStatus(order.ID, billing.StatusPending, ykResp.ID); err != nil {\n\t\treturn nil, \"\", err\n\t}\n\n\treturn order, ykResp.Confirmation.URL, nil\n}\n\n// ProcessWebhook — вызывается из HTTP хендлера\nfunc (s *Service) ProcessWebhook(ctx context.Context, event yookassa.WebhookEvent) error {\n\tif event.Event != \"payment.succeeded\" {\n\t\treturn nil\n\t}\n\n\torderIDStr := event.Object.Metadata[\"order_id\"]\n\torderID, err := uuid.Parse(orderIDStr)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\torder, err := s.billingRepo.GetOrder(orderID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif order.Status == billing.StatusPaid {\n\t\treturn nil\n\t}\n\n\treturn s.applyOrderToBalance(order, event.Object.ID)\n}\n\n// applyOrderToBalance — внутренняя логика начисления (DRY)\nfunc (s *Service) applyOrderToBalance(order *billing.Order, externalID string) error {\n\tvar tariff *billing.Tariff\n\tfor _, t := range AvailableTariffs {\n\t\tif t.ID == order.TariffID {\n\t\t\ttariff = &t\n\t\t\tbreak\n\t\t}\n\t}\n\n\tserver, err := s.accountRepo.GetServerByID(order.TargetServerID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tnow := time.Now()\n\tvar newPaidUntil time.Time\n\tif server.PaidUntil != nil && server.PaidUntil.After(now) {\n\t\tnewPaidUntil = server.PaidUntil.AddDate(0, 0, tariff.DurationDays)\n\t} else {\n\t\tnewPaidUntil = now.AddDate(0, 0, tariff.DurationDays)\n\t}\n\n\tif err := s.accountRepo.UpdateBalance(server.ID, tariff.InvoicesCount, &newPaidUntil); err != nil {\n\t\treturn err\n\t}\n\n\tif s.notifier != nil {\n\t\t// Мы запускаем в горутине, чтобы не тормозить ответ ЮКассе\n\t\tgo s.notifier.NotifySuccess(order.UserID, order.Amount, server.Balance+tariff.InvoicesCount, server.Name)\n\t}\n\n\treturn s.billingRepo.UpdateOrderStatus(order.ID, billing.StatusPaid, externalID)\n}\n\n// ConfirmOrder — оставляем для совместимости или ручного подтверждения (если нужно)\nfunc (s *Service) ConfirmOrder(orderID uuid.UUID) error {\n\torder, err := s.billingRepo.GetOrder(orderID)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn s.applyOrderToBalance(order, \"manual_confirmation\")\n}\n\n// CanProcessInvoice проверяет возможность отправки накладной\nfunc (s *Service) CanProcessInvoice(serverID uuid.UUID) (bool, error) {\n\tserver, err := s.accountRepo.GetServerByID(serverID)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\tif server.Balance <= 0 {\n\t\treturn false, errors.New(\"недостаточно накладных на балансе\")\n\t}\n\n\tif server.PaidUntil == nil || server.PaidUntil.Before(time.Now()) {\n\t\treturn false, errors.New(\"срок действия услуг истек\")\n\t}\n\n\treturn true, nil\n}\n", + "internal/services/drafts/service.go": "package drafts\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/shopspring/decimal\"\n\t\"go.uber.org/zap\"\n\n\t\"rmser/internal/domain/account\"\n\t\"rmser/internal/domain/catalog\"\n\t\"rmser/internal/domain/drafts\"\n\t\"rmser/internal/domain/invoices\"\n\t\"rmser/internal/domain/ocr\"\n\t\"rmser/internal/domain/photos\"\n\t\"rmser/internal/domain/suppliers\"\n\t\"rmser/internal/infrastructure/rms\"\n\t\"rmser/internal/services/billing\"\n\t\"rmser/pkg/logger\"\n)\n\ntype Service struct {\n\tdraftRepo drafts.Repository\n\tocrRepo ocr.Repository\n\tcatalogRepo catalog.Repository\n\taccountRepo account.Repository\n\tsupplierRepo suppliers.Repository\n\tinvoiceRepo invoices.Repository\n\tphotoRepo photos.Repository\n\trmsFactory *rms.Factory\n\tbillingService *billing.Service\n}\n\nfunc NewService(\n\tdraftRepo drafts.Repository,\n\tocrRepo ocr.Repository,\n\tcatalogRepo catalog.Repository,\n\taccountRepo account.Repository,\n\tsupplierRepo suppliers.Repository,\n\tphotoRepo photos.Repository,\n\tinvoiceRepo invoices.Repository,\n\trmsFactory *rms.Factory,\n\tbillingService *billing.Service,\n) *Service {\n\treturn &Service{\n\t\tdraftRepo: draftRepo,\n\t\tocrRepo: ocrRepo,\n\t\tcatalogRepo: catalogRepo,\n\t\taccountRepo: accountRepo,\n\t\tsupplierRepo: supplierRepo,\n\t\tphotoRepo: photoRepo,\n\t\tinvoiceRepo: invoiceRepo,\n\t\trmsFactory: rmsFactory,\n\t\tbillingService: billingService,\n\t}\n}\n\nfunc (s *Service) checkWriteAccess(userID, serverID uuid.UUID) error {\n\trole, err := s.accountRepo.GetUserRole(userID, serverID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif role == account.RoleOperator {\n\t\treturn errors.New(\"доступ запрещен: оператор не может редактировать данные\")\n\t}\n\treturn nil\n}\n\nfunc (s *Service) GetDraft(draftID, userID uuid.UUID) (*drafts.DraftInvoice, error) {\n\tdraft, err := s.draftRepo.GetByID(draftID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\treturn nil, errors.New(\"нет активного сервера\")\n\t}\n\n\tif draft.RMSServerID != server.ID {\n\t\treturn nil, errors.New(\"черновик не принадлежит активному серверу\")\n\t}\n\n\tif err := s.checkWriteAccess(userID, server.ID); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn draft, nil\n}\n\nfunc (s *Service) GetActiveDrafts(userID uuid.UUID) ([]drafts.DraftInvoice, error) {\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\treturn nil, errors.New(\"активный сервер не выбран\")\n\t}\n\n\tif err := s.checkWriteAccess(userID, server.ID); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn s.draftRepo.GetActive(server.ID)\n}\n\nfunc (s *Service) GetDictionaries(userID uuid.UUID) (map[string]interface{}, error) {\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\treturn nil, fmt.Errorf(\"active server not found\")\n\t}\n\n\tif err := s.checkWriteAccess(userID, server.ID); err != nil {\n\t\treturn nil, err\n\t}\n\n\tstores, _ := s.catalogRepo.GetActiveStores(server.ID)\n\tsuppliersList, _ := s.supplierRepo.GetRankedByUsage(server.ID, 90)\n\n\treturn map[string]interface{}{\n\t\t\"stores\": stores,\n\t\t\"suppliers\": suppliersList,\n\t}, nil\n}\n\nfunc (s *Service) DeleteDraft(id uuid.UUID) (string, error) {\n\tdraft, err := s.draftRepo.GetByID(id)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// Логика статусов\n\tif draft.Status == drafts.StatusCanceled {\n\t\t// Окончательное удаление\n\t\tif err := s.draftRepo.Delete(id); err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\t// ВАЖНО: Разрываем связь с фото (оно становится ORPHAN)\n\t\tif err := s.photoRepo.ClearDraftLinkByDraftID(id); err != nil {\n\t\t\tlogger.Log.Error(\"failed to clear photo draft link\", zap.Error(err))\n\t\t}\n\t\treturn drafts.StatusDeleted, nil\n\t}\n\n\tif draft.Status != drafts.StatusCompleted {\n\t\tdraft.Status = drafts.StatusCanceled\n\t\ts.draftRepo.Update(draft)\n\t\treturn drafts.StatusCanceled, nil\n\t}\n\n\treturn draft.Status, nil\n}\n\nfunc (s *Service) UpdateDraftHeader(id uuid.UUID, storeID *uuid.UUID, supplierID *uuid.UUID, date time.Time, comment string, incomingDocNum string) error {\n\tdraft, err := s.draftRepo.GetByID(id)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif draft.Status == drafts.StatusCompleted {\n\t\treturn errors.New(\"черновик уже отправлен\")\n\t}\n\tdraft.StoreID = storeID\n\tdraft.SupplierID = supplierID\n\tdraft.DateIncoming = &date\n\tdraft.Comment = comment\n\tdraft.IncomingDocumentNumber = incomingDocNum\n\treturn s.draftRepo.Update(draft)\n}\n\nfunc (s *Service) AddItem(draftID uuid.UUID) (*drafts.DraftInvoiceItem, error) {\n\tnewItem := &drafts.DraftInvoiceItem{\n\t\tID: uuid.New(),\n\t\tDraftID: draftID,\n\t\tRawName: \"Новая позиция\",\n\t\tRawAmount: decimal.NewFromFloat(1),\n\t\tRawPrice: decimal.Zero,\n\t\tQuantity: decimal.NewFromFloat(1),\n\t\tPrice: decimal.Zero,\n\t\tSum: decimal.Zero,\n\t\tIsMatched: false,\n\t\tLastEditedField1: drafts.FieldQuantity,\n\t\tLastEditedField2: drafts.FieldPrice,\n\t}\n\n\tif err := s.draftRepo.CreateItem(newItem); err != nil {\n\t\treturn nil, err\n\t}\n\treturn newItem, nil\n}\n\nfunc (s *Service) DeleteItem(draftID, itemID uuid.UUID) (float64, error) {\n\tif err := s.draftRepo.DeleteItem(itemID); err != nil {\n\t\treturn 0, err\n\t}\n\n\tdraft, err := s.draftRepo.GetByID(draftID)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\tvar totalSum decimal.Decimal\n\tfor _, item := range draft.Items {\n\t\tif !item.Sum.IsZero() {\n\t\t\ttotalSum = totalSum.Add(item.Sum)\n\t\t} else {\n\t\t\ttotalSum = totalSum.Add(item.Quantity.Mul(item.Price))\n\t\t}\n\t}\n\n\tsumFloat, _ := totalSum.Float64()\n\treturn sumFloat, nil\n}\n\n// RecalculateItemFields - логика пересчета Qty/Price/Sum\nfunc (s *Service) RecalculateItemFields(item *drafts.DraftInvoiceItem, editedField drafts.EditedField) {\n\tif item.LastEditedField1 != editedField {\n\t\titem.LastEditedField2 = item.LastEditedField1\n\t\titem.LastEditedField1 = editedField\n\t}\n\n\tfieldsToKeep := map[drafts.EditedField]bool{\n\t\titem.LastEditedField1: true,\n\t\titem.LastEditedField2: true,\n\t}\n\n\tvar fieldToRecalc drafts.EditedField\n\tfieldToRecalc = drafts.FieldSum // Default fallback\n\n\tfor _, f := range []drafts.EditedField{drafts.FieldQuantity, drafts.FieldPrice, drafts.FieldSum} {\n\t\tif !fieldsToKeep[f] {\n\t\t\tfieldToRecalc = f\n\t\t\tbreak\n\t\t}\n\t}\n\n\tswitch fieldToRecalc {\n\tcase drafts.FieldQuantity:\n\t\tif !item.Price.IsZero() {\n\t\t\titem.Quantity = item.Sum.Div(item.Price)\n\t\t} else {\n\t\t\titem.Quantity = decimal.Zero\n\t\t}\n\tcase drafts.FieldPrice:\n\t\tif !item.Quantity.IsZero() {\n\t\t\titem.Price = item.Sum.Div(item.Quantity)\n\t\t} else {\n\t\t\titem.Price = decimal.Zero\n\t\t}\n\tcase drafts.FieldSum:\n\t\titem.Sum = item.Quantity.Mul(item.Price)\n\t}\n}\n\n// UpdateItem обновлен для поддержки динамического пересчета\nfunc (s *Service) UpdateItem(draftID, itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price, sum decimal.Decimal, editedField string) error {\n\tdraft, err := s.draftRepo.GetByID(draftID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tcurrentItem, err := s.draftRepo.GetItemByID(itemID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif productID != nil {\n\t\tcurrentItem.ProductID = productID\n\t\tcurrentItem.IsMatched = true\n\t}\n\n\tif containerID != nil {\n\t\t// Если пришел UUID.Nil, значит сброс\n\t\tif *containerID == uuid.Nil {\n\t\t\tcurrentItem.ContainerID = nil\n\t\t} else {\n\t\t\tcurrentItem.ContainerID = containerID\n\t\t}\n\t}\n\n\tfield := drafts.EditedField(editedField)\n\tswitch field {\n\tcase drafts.FieldQuantity:\n\t\tcurrentItem.Quantity = qty\n\tcase drafts.FieldPrice:\n\t\tcurrentItem.Price = price\n\tcase drafts.FieldSum:\n\t\tcurrentItem.Sum = sum\n\t}\n\n\ts.RecalculateItemFields(currentItem, field)\n\n\tif draft.Status == drafts.StatusCanceled {\n\t\tdraft.Status = drafts.StatusReadyToVerify\n\t\ts.draftRepo.Update(draft)\n\t}\n\n\tupdates := map[string]interface{}{\n\t\t\"product_id\": currentItem.ProductID,\n\t\t\"container_id\": currentItem.ContainerID,\n\t\t\"quantity\": currentItem.Quantity,\n\t\t\"price\": currentItem.Price,\n\t\t\"sum\": currentItem.Sum,\n\t\t\"last_edited_field1\": currentItem.LastEditedField1,\n\t\t\"last_edited_field2\": currentItem.LastEditedField2,\n\t\t\"is_matched\": currentItem.IsMatched,\n\t}\n\n\treturn s.draftRepo.UpdateItem(itemID, updates)\n}\n\nfunc (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) {\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"active server not found: %w\", err)\n\t}\n\n\tif err := s.checkWriteAccess(userID, server.ID); err != nil {\n\t\treturn \"\", err\n\t}\n\n\tif can, err := s.billingService.CanProcessInvoice(server.ID); !can {\n\t\treturn \"\", fmt.Errorf(\"ошибка биллинга: %w\", err)\n\t}\n\n\tdraft, err := s.draftRepo.GetByID(draftID)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tif draft.RMSServerID != server.ID {\n\t\treturn \"\", errors.New(\"черновик принадлежит другому серверу\")\n\t}\n\n\tif draft.Status == drafts.StatusCompleted {\n\t\treturn \"\", errors.New(\"накладная уже отправлена\")\n\t}\n\n\tclient, err := s.rmsFactory.GetClientForUser(userID)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\ttargetStatus := \"NEW\"\n\tif server.AutoProcess {\n\t\ttargetStatus = \"PROCESSED\"\n\t}\n\n\tinv := invoices.Invoice{\n\t\tID: uuid.Nil,\n\t\tDocumentNumber: draft.DocumentNumber,\n\t\tDateIncoming: *draft.DateIncoming,\n\t\tSupplierID: *draft.SupplierID,\n\t\tDefaultStoreID: *draft.StoreID,\n\t\tStatus: targetStatus,\n\t\tComment: draft.Comment,\n\t\tIncomingDocumentNumber: draft.IncomingDocumentNumber,\n\t\tItems: make([]invoices.InvoiceItem, 0, len(draft.Items)),\n\t}\n\n\tfor _, dItem := range draft.Items {\n\t\tif dItem.ProductID == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tsum := dItem.Sum\n\t\tif sum.IsZero() {\n\t\t\tsum = dItem.Quantity.Mul(dItem.Price)\n\t\t}\n\n\t\tamountToSend := dItem.Quantity\n\t\tpriceToSend := dItem.Price\n\n\t\tif dItem.ContainerID != nil && *dItem.ContainerID != uuid.Nil {\n\t\t\tif dItem.Container != nil {\n\t\t\t\tif !dItem.Container.Count.IsZero() {\n\t\t\t\t\tamountToSend = dItem.Quantity.Mul(dItem.Container.Count)\n\t\t\t\t\tpriceToSend = dItem.Price.Div(dItem.Container.Count)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tlogger.Log.Warn(\"Container struct is nil for item with ContainerID\",\n\t\t\t\t\tzap.String(\"item_id\", dItem.ID.String()),\n\t\t\t\t\tzap.String(\"container_id\", dItem.ContainerID.String()))\n\t\t\t}\n\t\t}\n\n\t\tinvItem := invoices.InvoiceItem{\n\t\t\tProductID: *dItem.ProductID,\n\t\t\tAmount: amountToSend,\n\t\t\tPrice: priceToSend,\n\t\t\tSum: sum,\n\t\t\tContainerID: dItem.ContainerID,\n\t\t}\n\t\tinv.Items = append(inv.Items, invItem)\n\t}\n\n\tif len(inv.Items) == 0 {\n\t\treturn \"\", errors.New(\"нет распознанных позиций для отправки\")\n\t}\n\n\tdocNum, err := client.CreateIncomingInvoice(inv)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tinvoices, err := client.FetchInvoices(*draft.DateIncoming, *draft.DateIncoming)\n\tif err != nil {\n\t\tlogger.Log.Warn(\"Не удалось получить список накладных для поиска UUID\", zap.Error(err), zap.Time(\"date\", *draft.DateIncoming))\n\t} else {\n\t\tfound := false\n\t\tfor _, invoice := range invoices {\n\t\t\tif invoice.DocumentNumber == docNum {\n\t\t\t\tdraft.RMSInvoiceID = &invoice.ID\n\t\t\t\tfound = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif !found {\n\t\t\tlogger.Log.Warn(\"UUID созданной накладной не найден\", zap.String(\"document_number\", docNum), zap.Time(\"date\", *draft.DateIncoming))\n\t\t}\n\t}\n\n\tdraft.Status = drafts.StatusCompleted\n\ts.draftRepo.Update(draft)\n\n\tif err := s.accountRepo.DecrementBalance(server.ID); err != nil {\n\t\tlogger.Log.Error(\"Billing decrement failed\", zap.Error(err), zap.String(\"server_id\", server.ID.String()))\n\t}\n\n\tif err := s.accountRepo.IncrementInvoiceCount(server.ID); err != nil {\n\t\tlogger.Log.Error(\"Billing increment failed\", zap.Error(err))\n\t}\n\n\tgo s.learnFromDraft(draft, server.ID)\n\n\treturn docNum, nil\n}\n\nfunc (s *Service) learnFromDraft(draft *drafts.DraftInvoice, serverID uuid.UUID) {\n\tfor _, item := range draft.Items {\n\t\tif item.RawName != \"\" && item.ProductID != nil {\n\t\t\tqty := decimal.NewFromFloat(1.0)\n\t\t\terr := s.ocrRepo.SaveMatch(serverID, item.RawName, *item.ProductID, qty, item.ContainerID)\n\t\t\tif err != nil {\n\t\t\t\tlogger.Log.Warn(\"Failed to learn match\", zap.Error(err))\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (s *Service) CreateProductContainer(userID uuid.UUID, productID uuid.UUID, name string, count decimal.Decimal) (uuid.UUID, error) {\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\treturn uuid.Nil, errors.New(\"no active server\")\n\t}\n\tif err := s.checkWriteAccess(userID, server.ID); err != nil {\n\t\treturn uuid.Nil, err\n\t}\n\n\tclient, err := s.rmsFactory.GetClientForUser(userID)\n\tif err != nil {\n\t\treturn uuid.Nil, err\n\t}\n\n\tfullProduct, err := client.GetProductByID(productID)\n\tif err != nil {\n\t\treturn uuid.Nil, fmt.Errorf(\"error fetching product: %w\", err)\n\t}\n\n\ttargetCount, _ := count.Float64()\n\tfor _, c := range fullProduct.Containers {\n\t\tif !c.Deleted && (c.Name == name || (c.Count == targetCount)) {\n\t\t\tif c.ID != nil && *c.ID != \"\" {\n\t\t\t\treturn uuid.Parse(*c.ID)\n\t\t\t}\n\t\t}\n\t}\n\n\tmaxNum := 0\n\tfor _, c := range fullProduct.Containers {\n\t\tif n, err := strconv.Atoi(c.Num); err == nil {\n\t\t\tif n > maxNum {\n\t\t\t\tmaxNum = n\n\t\t\t}\n\t\t}\n\t}\n\tnextNum := strconv.Itoa(maxNum + 1)\n\n\tnewContainerDTO := rms.ContainerFullDTO{\n\t\tID: nil,\n\t\tNum: nextNum,\n\t\tName: name,\n\t\tCount: targetCount,\n\t\tUseInFront: true,\n\t\tDeleted: false,\n\t}\n\tfullProduct.Containers = append(fullProduct.Containers, newContainerDTO)\n\n\tupdatedProduct, err := client.UpdateProduct(*fullProduct)\n\tif err != nil {\n\t\treturn uuid.Nil, fmt.Errorf(\"error updating product: %w\", err)\n\t}\n\n\tvar createdID uuid.UUID\n\tfound := false\n\tfor _, c := range updatedProduct.Containers {\n\t\tif c.Name == name && c.Count == targetCount && !c.Deleted {\n\t\t\tif c.ID != nil {\n\t\t\t\tcreatedID, err = uuid.Parse(*c.ID)\n\t\t\t\tif err == nil {\n\t\t\t\t\tfound = true\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tif !found {\n\t\treturn uuid.Nil, errors.New(\"container created but id not found\")\n\t}\n\n\tnewLocalContainer := catalog.ProductContainer{\n\t\tID: createdID,\n\t\tRMSServerID: server.ID,\n\t\tProductID: productID,\n\t\tName: name,\n\t\tCount: count,\n\t}\n\ts.catalogRepo.SaveContainer(newLocalContainer)\n\n\treturn createdID, nil\n}\n\ntype UnifiedInvoiceDTO struct {\n\tID uuid.UUID `json:\"id\"`\n\tType string `json:\"type\"`\n\tDocumentNumber string `json:\"document_number\"`\n\tIncomingNumber string `json:\"incoming_number\"`\n\tDateIncoming time.Time `json:\"date_incoming\"`\n\tStatus string `json:\"status\"`\n\tTotalSum float64 `json:\"total_sum\"`\n\tStoreName string `json:\"store_name\"`\n\tItemsCount int `json:\"items_count\"`\n\tCreatedAt time.Time `json:\"created_at\"`\n\tIsAppCreated bool `json:\"is_app_created\"`\n\tPhotoURL string `json:\"photo_url\"`\n\tItemsPreview string `json:\"items_preview\"`\n}\n\nfunc (s *Service) GetUnifiedList(userID uuid.UUID, from, to time.Time) ([]UnifiedInvoiceDTO, error) {\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\treturn nil, errors.New(\"активный сервер не выбран\")\n\t}\n\n\tdraftsList, err := s.draftRepo.GetActive(server.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tinvoicesList, err := s.invoiceRepo.GetByPeriod(server.ID, from, to)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tphotoMap, err := s.draftRepo.GetRMSInvoiceIDToPhotoURLMap(server.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tresult := make([]UnifiedInvoiceDTO, 0, len(draftsList)+len(invoicesList))\n\n\tfor _, d := range draftsList {\n\t\tvar sum decimal.Decimal\n\t\tfor _, it := range d.Items {\n\t\t\tif !it.Sum.IsZero() {\n\t\t\t\tsum = sum.Add(it.Sum)\n\t\t\t} else {\n\t\t\t\tsum = sum.Add(it.Quantity.Mul(it.Price))\n\t\t\t}\n\t\t}\n\n\t\tval, _ := sum.Float64()\n\t\tdate := time.Now()\n\t\tif d.DateIncoming != nil {\n\t\t\tdate = *d.DateIncoming\n\t\t}\n\n\t\tvar itemsPreview string\n\t\tif len(d.Items) > 0 {\n\t\t\tnames := make([]string, 0, 3)\n\t\t\tfor i, it := range d.Items {\n\t\t\t\tif i >= 3 {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tnames = append(names, it.RawName)\n\t\t\t}\n\t\t\titemsPreview = strings.Join(names, \", \")\n\t\t}\n\n\t\tresult = append(result, UnifiedInvoiceDTO{\n\t\t\tID: d.ID,\n\t\t\tType: \"DRAFT\",\n\t\t\tDocumentNumber: d.DocumentNumber,\n\t\t\tIncomingNumber: \"\",\n\t\t\tDateIncoming: date,\n\t\t\tStatus: d.Status,\n\t\t\tTotalSum: val,\n\t\t\tStoreName: \"\",\n\t\t\tItemsCount: len(d.Items),\n\t\t\tCreatedAt: d.CreatedAt,\n\t\t\tIsAppCreated: true,\n\t\t\tPhotoURL: d.SenderPhotoURL,\n\t\t\tItemsPreview: itemsPreview,\n\t\t})\n\t}\n\n\tfor _, inv := range invoicesList {\n\t\tvar sum decimal.Decimal\n\t\tfor _, it := range inv.Items {\n\t\t\tsum = sum.Add(it.Sum)\n\t\t}\n\t\tval, _ := sum.Float64()\n\n\t\tisAppCreated := false\n\t\tphotoURL := \"\"\n\t\tif url, exists := photoMap[inv.ID]; exists {\n\t\t\tisAppCreated = true\n\t\t\tphotoURL = url\n\t\t}\n\n\t\tvar itemsPreview string\n\t\tif len(inv.Items) > 0 {\n\t\t\tnames := make([]string, 0, 3)\n\t\t\tfor i, it := range inv.Items {\n\t\t\t\tif i >= 3 {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tif it.Product.Name != \"\" {\n\t\t\t\t\tnames = append(names, it.Product.Name)\n\t\t\t\t}\n\t\t\t}\n\t\t\titemsPreview = strings.Join(names, \", \")\n\t\t}\n\n\t\tresult = append(result, UnifiedInvoiceDTO{\n\t\t\tID: inv.ID,\n\t\t\tType: \"SYNCED\",\n\t\t\tDocumentNumber: inv.DocumentNumber,\n\t\t\tIncomingNumber: inv.IncomingDocumentNumber,\n\t\t\tDateIncoming: inv.DateIncoming,\n\t\t\tStatus: inv.Status,\n\t\t\tTotalSum: val,\n\t\t\tItemsCount: len(inv.Items),\n\t\t\tCreatedAt: inv.CreatedAt,\n\t\t\tIsAppCreated: isAppCreated,\n\t\t\tPhotoURL: photoURL,\n\t\t\tItemsPreview: itemsPreview,\n\t\t})\n\t}\n\n\treturn result, nil\n}\n\nfunc (s *Service) GetInvoiceDetails(invoiceID, userID uuid.UUID) (*invoices.Invoice, string, error) {\n\tinv, err := s.invoiceRepo.GetByID(invoiceID)\n\tif err != nil {\n\t\treturn nil, \"\", err\n\t}\n\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\treturn nil, \"\", errors.New(\"нет активного сервера\")\n\t}\n\n\tif inv.RMSServerID != server.ID {\n\t\treturn nil, \"\", errors.New(\"накладная не принадлежит активному серверу\")\n\t}\n\n\tdraft, err := s.draftRepo.GetByRMSInvoiceID(invoiceID)\n\tif err != nil {\n\t\treturn nil, \"\", err\n\t}\n\n\tphotoURL := \"\"\n\tif draft != nil {\n\t\tphotoURL = draft.SenderPhotoURL\n\t}\n\n\treturn inv, photoURL, nil\n}\n", + "internal/services/invoices/service.go": "package invoices\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/shopspring/decimal\"\n\t\"go.uber.org/zap\"\n\n\t\"rmser/internal/domain/drafts\"\n\tinvDomain \"rmser/internal/domain/invoices\"\n\t\"rmser/internal/domain/suppliers\"\n\t\"rmser/internal/infrastructure/rms\"\n\t\"rmser/pkg/logger\"\n)\n\ntype Service struct {\n\trepo invDomain.Repository\n\tdraftsRepo drafts.Repository\n\tsupplierRepo suppliers.Repository\n\trmsFactory *rms.Factory\n\t// Здесь можно добавить репозитории каталога и контрагентов для валидации,\n\t// но для краткости пока опустим глубокую валидацию.\n}\n\nfunc NewService(repo invDomain.Repository, draftsRepo drafts.Repository, supplierRepo suppliers.Repository, rmsFactory *rms.Factory) *Service {\n\treturn &Service{\n\t\trepo: repo,\n\t\tdraftsRepo: draftsRepo,\n\t\tsupplierRepo: supplierRepo,\n\t\trmsFactory: rmsFactory,\n\t}\n}\n\n// CreateRequestDTO - структура входящего JSON запроса от фронта/OCR\ntype CreateRequestDTO struct {\n\tDocumentNumber string `json:\"document_number\"`\n\tDateIncoming string `json:\"date_incoming\"` // YYYY-MM-DD\n\tSupplierID uuid.UUID `json:\"supplier_id\"`\n\tStoreID uuid.UUID `json:\"store_id\"`\n\tItems []struct {\n\t\tProductID uuid.UUID `json:\"product_id\"`\n\t\tAmount decimal.Decimal `json:\"amount\"`\n\t\tPrice decimal.Decimal `json:\"price\"`\n\t} `json:\"items\"`\n}\n\n// SendInvoiceToRMS валидирует DTO, собирает доменную модель и отправляет в RMS\nfunc (s *Service) SendInvoiceToRMS(req CreateRequestDTO, userID uuid.UUID) (string, error) {\n\t// 1. Базовая валидация\n\tif len(req.Items) == 0 {\n\t\treturn \"\", fmt.Errorf(\"список товаров пуст\")\n\t}\n\n\tdateInc, err := time.Parse(\"2006-01-02\", req.DateIncoming)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"неверный формат даты (ожидается YYYY-MM-DD): %v\", err)\n\t}\n\n\t// 2. Сборка доменной модели\n\tinv := invDomain.Invoice{\n\t\tID: uuid.Nil, // Новый документ\n\t\tDocumentNumber: req.DocumentNumber,\n\t\tDateIncoming: dateInc,\n\t\tSupplierID: req.SupplierID,\n\t\tDefaultStoreID: req.StoreID,\n\t\tStatus: \"NEW\",\n\t\tItems: make([]invDomain.InvoiceItem, 0, len(req.Items)),\n\t}\n\n\tfor _, itemDTO := range req.Items {\n\t\tsum := itemDTO.Amount.Mul(itemDTO.Price) // Пересчитываем сумму\n\n\t\tinv.Items = append(inv.Items, invDomain.InvoiceItem{\n\t\t\tProductID: itemDTO.ProductID,\n\t\t\tAmount: itemDTO.Amount,\n\t\t\tPrice: itemDTO.Price,\n\t\t\tSum: sum,\n\t\t})\n\t}\n\n\t// 3. Получение клиента RMS\n\tclient, err := s.rmsFactory.GetClientForUser(userID)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"ошибка получения клиента RMS: %w\", err)\n\t}\n\n\t// 4. Отправка через клиент\n\tlogger.Log.Info(\"Отправка накладной в RMS\",\n\t\tzap.String(\"supplier\", req.SupplierID.String()),\n\t\tzap.Int(\"items_count\", len(inv.Items)))\n\n\tdocNum, err := client.CreateIncomingInvoice(inv)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn docNum, nil\n}\n\n// InvoiceDetailsDTO - DTO для ответа на запрос деталей накладной\ntype InvoiceDetailsDTO struct {\n\tID uuid.UUID `json:\"id\"`\n\tNumber string `json:\"number\"`\n\tDate string `json:\"date\"`\n\tStatus string `json:\"status\"`\n\tSupplier struct {\n\t\tID uuid.UUID `json:\"id\"`\n\t\tName string `json:\"name\"`\n\t} `json:\"supplier\"`\n\tItems []struct {\n\t\tName string `json:\"name\"`\n\t\tQuantity float64 `json:\"quantity\"`\n\t\tPrice float64 `json:\"price\"`\n\t\tTotal float64 `json:\"total\"`\n\t} `json:\"items\"`\n\tPhotoURL *string `json:\"photo_url\"`\n}\n\n// GetInvoice возвращает детали синхронизированной накладной по ID\nfunc (s *Service) GetInvoice(id uuid.UUID) (*InvoiceDetailsDTO, error) {\n\t// 1. Получить накладную из репозитория\n\tinv, err := s.repo.GetByID(id)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"ошибка получения накладной: %w\", err)\n\t}\n\n\t// 2. Получить поставщика\n\tsupplier, err := s.supplierRepo.GetByID(inv.SupplierID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"ошибка получения поставщика: %w\", err)\n\t}\n\n\t// 3. Проверить, есть ли draft с photo_url\n\tvar photoURL *string\n\tdraft, err := s.draftsRepo.GetByRMSInvoiceID(inv.ID)\n\tif err == nil && draft != nil {\n\t\tphotoURL = &draft.SenderPhotoURL\n\t}\n\n\t// 4. Собрать DTO\n\tdto := &InvoiceDetailsDTO{\n\t\tID: inv.ID,\n\t\tNumber: inv.DocumentNumber,\n\t\tDate: inv.DateIncoming.Format(\"2006-01-02\"),\n\t\tStatus: \"COMPLETED\", // Для синхронизированных накладных статус всегда COMPLETED\n\t\tItems: make([]struct {\n\t\t\tName string `json:\"name\"`\n\t\t\tQuantity float64 `json:\"quantity\"`\n\t\t\tPrice float64 `json:\"price\"`\n\t\t\tTotal float64 `json:\"total\"`\n\t\t}, len(inv.Items)),\n\t\tPhotoURL: photoURL,\n\t}\n\n\tdto.Supplier.ID = supplier.ID\n\tdto.Supplier.Name = supplier.Name\n\n\tfor i, item := range inv.Items {\n\t\tdto.Items[i].Name = item.Product.Name\n\t\tdto.Items[i].Quantity, _ = item.Amount.Float64()\n\t\tdto.Items[i].Price, _ = item.Price.Float64()\n\t\tdto.Items[i].Total, _ = item.Sum.Float64()\n\t}\n\n\treturn dto, nil\n}\n", + "internal/services/ocr/service.go": "package ocr\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/shopspring/decimal\"\n\n\t\"rmser/internal/domain/account\"\n\t\"rmser/internal/domain/catalog\"\n\t\"rmser/internal/domain/drafts\"\n\t\"rmser/internal/domain/ocr\"\n\t\"rmser/internal/domain/photos\"\n\t\"rmser/internal/infrastructure/ocr_client\"\n)\n\n// DevNotifier - интерфейс для уведомления разработчиков\ntype DevNotifier interface {\n\tNotifyDevs(devIDs []int64, photoPath string, serverName string, serverID string)\n}\n\ntype Service struct {\n\tocrRepo ocr.Repository\n\tcatalogRepo catalog.Repository\n\tdraftRepo drafts.Repository\n\taccountRepo account.Repository\n\tphotoRepo photos.Repository\n\tpyClient *ocr_client.Client\n\tstoragePath string\n\tnotifier DevNotifier\n\tdevIDs []int64\n}\n\nfunc NewService(\n\tocrRepo ocr.Repository,\n\tcatalogRepo catalog.Repository,\n\tdraftRepo drafts.Repository,\n\taccountRepo account.Repository,\n\tphotoRepo photos.Repository,\n\tpyClient *ocr_client.Client,\n\tstoragePath string,\n) *Service {\n\treturn &Service{\n\t\tocrRepo: ocrRepo,\n\t\tcatalogRepo: catalogRepo,\n\t\tdraftRepo: draftRepo,\n\t\taccountRepo: accountRepo,\n\t\tphotoRepo: photoRepo,\n\t\tpyClient: pyClient,\n\t\tstoragePath: storagePath,\n\t}\n}\n\n// SetNotifier - устанавливает notifier для уведомлений разработчиков\nfunc (s *Service) SetNotifier(n DevNotifier) {\n\ts.notifier = n\n}\n\n// SetDevIDs - устанавливает список ID разработчиков для уведомлений\nfunc (s *Service) SetDevIDs(ids []int64) {\n\ts.devIDs = ids\n}\n\n// checkWriteAccess - вспомогательный метод проверки прав\nfunc (s *Service) checkWriteAccess(userID, serverID uuid.UUID) error {\n\trole, err := s.accountRepo.GetUserRole(userID, serverID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif role == account.RoleOperator {\n\t\treturn errors.New(\"access denied: operators cannot modify data\")\n\t}\n\treturn nil\n}\n\n// ProcessReceiptImage - Доступно всем (включая Операторов)\nfunc (s *Service) ProcessReceiptImage(ctx context.Context, userID uuid.UUID, imgData []byte) (*drafts.DraftInvoice, error) {\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\treturn nil, fmt.Errorf(\"no active server for user\")\n\t}\n\tserverID := server.ID\n\n\t// 1. Создаем ID для фото и черновика\n\tphotoID := uuid.New()\n\tdraftID := uuid.New()\n\n\tfileName := fmt.Sprintf(\"receipt_%s.jpg\", photoID.String())\n\tfilePath := filepath.Join(s.storagePath, serverID.String(), fileName)\n\n\t// 2. Создаем директорию если не существует\n\tif err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create directory: %w\", err)\n\t}\n\n\t// 3. Сохраняем файл\n\tif err := os.WriteFile(filePath, imgData, 0644); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to save image: %w\", err)\n\t}\n\tfileURL := fmt.Sprintf(\"/uploads/%s/%s\", serverID.String(), fileName)\n\n\t// 4. Создаем запись ReceiptPhoto\n\tphoto := &photos.ReceiptPhoto{\n\t\tID: photoID,\n\t\tRMSServerID: serverID,\n\t\tUploadedBy: userID,\n\t\tFilePath: filePath,\n\t\tFileURL: fileURL,\n\t\tFileName: fileName,\n\t\tFileSize: int64(len(imgData)),\n\t\tDraftID: &draftID, // Сразу связываем с будущим черновиком\n\t\tCreatedAt: time.Now(),\n\t}\n\tif err := s.photoRepo.Create(photo); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create photo record: %w\", err)\n\t}\n\n\t// 5. Создаем черновик\n\tdraft := &drafts.DraftInvoice{\n\t\tID: draftID,\n\t\tUserID: userID,\n\t\tRMSServerID: serverID,\n\t\tStatus: drafts.StatusProcessing,\n\t\tStoreID: server.DefaultStoreID,\n\t\tSenderPhotoURL: fileURL, // Оставляем для совместимости, но теперь есть ReceiptPhoto\n\t}\n\n\tif err := s.draftRepo.Create(draft); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create draft: %w\", err)\n\t}\n\n\t// Уведомляем разработчиков если devIDs заданы\n\tif len(s.devIDs) > 0 && s.notifier != nil {\n\t\ts.notifier.NotifyDevs(s.devIDs, filePath, server.Name, serverID.String())\n\t}\n\n\t// 6. Отправляем в Python OCR\n\trawResult, err := s.pyClient.ProcessImage(ctx, imgData, \"receipt.jpg\")\n\tif err != nil {\n\t\tdraft.Status = drafts.StatusError\n\t\t_ = s.draftRepo.Update(draft)\n\t\treturn nil, fmt.Errorf(\"python ocr error: %w\", err)\n\t}\n\n\t// 6. Матчинг и сохранение позиций\n\tvar draftItems []drafts.DraftInvoiceItem\n\tfor _, rawItem := range rawResult.Items {\n\t\titem := drafts.DraftInvoiceItem{\n\t\t\tDraftID: draft.ID,\n\t\t\tRawName: rawItem.RawName,\n\t\t\tRawAmount: decimal.NewFromFloat(rawItem.Amount),\n\t\t\tRawPrice: decimal.NewFromFloat(rawItem.Price),\n\t\t\tQuantity: decimal.NewFromFloat(rawItem.Amount),\n\t\t\tPrice: decimal.NewFromFloat(rawItem.Price),\n\t\t\tSum: decimal.NewFromFloat(rawItem.Sum),\n\t\t}\n\n\t\tmatch, _ := s.ocrRepo.FindMatch(serverID, rawItem.RawName)\n\t\tif match != nil {\n\t\t\titem.IsMatched = true\n\t\t\titem.ProductID = &match.ProductID\n\t\t\titem.ContainerID = match.ContainerID\n\t\t} else {\n\t\t\ts.ocrRepo.UpsertUnmatched(serverID, rawItem.RawName)\n\t\t}\n\t\tdraftItems = append(draftItems, item)\n\t}\n\n\tdraft.Status = drafts.StatusReadyToVerify\n\ts.draftRepo.Update(draft)\n\ts.draftRepo.CreateItems(draftItems)\n\tdraft.Items = draftItems\n\n\treturn draft, nil\n}\n\n// Добавить структуры в конец файла\ntype ContainerForIndex struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tCount float64 `json:\"count\"`\n}\n\ntype ProductForIndex struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tCode string `json:\"code\"`\n\tMeasureUnit string `json:\"measure_unit\"`\n\tContainers []ContainerForIndex `json:\"containers\"`\n}\n\n// GetCatalogForIndexing - Только для админов/владельцев (т.к. используется для ручного матчинга)\nfunc (s *Service) GetCatalogForIndexing(userID uuid.UUID) ([]ProductForIndex, error) {\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\treturn nil, fmt.Errorf(\"no server\")\n\t}\n\tif err := s.checkWriteAccess(userID, server.ID); err != nil {\n\t\treturn nil, err\n\t}\n\n\tproducts, err := s.catalogRepo.GetActiveGoods(server.ID, server.RootGroupGUID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tresult := make([]ProductForIndex, 0, len(products))\n\tfor _, p := range products {\n\t\tuom := \"\"\n\t\tif p.MainUnit != nil {\n\t\t\tuom = p.MainUnit.Name\n\t\t}\n\n\t\tvar conts []ContainerForIndex\n\t\tfor _, c := range p.Containers {\n\t\t\tcnt, _ := c.Count.Float64()\n\t\t\tconts = append(conts, ContainerForIndex{\n\t\t\t\tID: c.ID.String(),\n\t\t\t\tName: c.Name,\n\t\t\t\tCount: cnt,\n\t\t\t})\n\t\t}\n\n\t\tresult = append(result, ProductForIndex{\n\t\t\tID: p.ID.String(),\n\t\t\tName: p.Name,\n\t\t\tCode: p.Code,\n\t\t\tMeasureUnit: uom,\n\t\t\tContainers: conts,\n\t\t})\n\t}\n\treturn result, nil\n}\n\nfunc (s *Service) SearchProducts(userID uuid.UUID, query string) ([]catalog.Product, error) {\n\tif len(query) < 2 {\n\t\treturn []catalog.Product{}, nil\n\t}\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\treturn nil, fmt.Errorf(\"no server\")\n\t}\n\t// Поиск нужен для матчинга, значит тоже защищаем\n\tif err := s.checkWriteAccess(userID, server.ID); err != nil {\n\t\treturn nil, err\n\t}\n\treturn s.catalogRepo.Search(server.ID, query, server.RootGroupGUID)\n}\n\nfunc (s *Service) SaveMapping(userID uuid.UUID, rawName string, productID uuid.UUID, quantity decimal.Decimal, containerID *uuid.UUID) error {\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\treturn fmt.Errorf(\"no server\")\n\t}\n\tif err := s.checkWriteAccess(userID, server.ID); err != nil {\n\t\treturn err\n\t}\n\treturn s.ocrRepo.SaveMatch(server.ID, rawName, productID, quantity, containerID)\n}\n\nfunc (s *Service) DeleteMatch(userID uuid.UUID, rawName string) error {\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\treturn fmt.Errorf(\"no server\")\n\t}\n\tif err := s.checkWriteAccess(userID, server.ID); err != nil {\n\t\treturn err\n\t}\n\treturn s.ocrRepo.DeleteMatch(server.ID, rawName)\n}\n\nfunc (s *Service) GetKnownMatches(userID uuid.UUID) ([]ocr.ProductMatch, error) {\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\treturn nil, fmt.Errorf(\"no server\")\n\t}\n\tif err := s.checkWriteAccess(userID, server.ID); err != nil {\n\t\treturn nil, err\n\t}\n\treturn s.ocrRepo.GetAllMatches(server.ID)\n}\n\nfunc (s *Service) GetUnmatchedItems(userID uuid.UUID) ([]ocr.UnmatchedItem, error) {\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\treturn nil, fmt.Errorf(\"no server\")\n\t}\n\tif err := s.checkWriteAccess(userID, server.ID); err != nil {\n\t\treturn nil, err\n\t}\n\treturn s.ocrRepo.GetTopUnmatched(server.ID, 50)\n}\n\nfunc (s *Service) DiscardUnmatched(userID uuid.UUID, rawName string) error {\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\treturn fmt.Errorf(\"no server\")\n\t}\n\tif err := s.checkWriteAccess(userID, server.ID); err != nil {\n\t\treturn err\n\t}\n\treturn s.ocrRepo.DeleteUnmatched(server.ID, rawName)\n}\n", + "internal/services/photos/service.go": "package photos\n\nimport (\n\t\"errors\"\n\t\"os\"\n\n\t\"github.com/google/uuid\"\n\n\t\"rmser/internal/domain/account\"\n\t\"rmser/internal/domain/drafts\"\n\t\"rmser/internal/domain/photos\"\n)\n\nvar (\n\tErrPhotoHasInvoice = errors.New(\"нельзя удалить фото: есть созданная накладная\")\n\tErrPhotoHasDraft = errors.New(\"фото связано с черновиком\")\n)\n\ntype Service struct {\n\tphotoRepo photos.Repository\n\tdraftRepo drafts.Repository\n\taccountRepo account.Repository\n}\n\nfunc NewService(photoRepo photos.Repository, draftRepo drafts.Repository, accountRepo account.Repository) *Service {\n\treturn &Service{\n\t\tphotoRepo: photoRepo,\n\t\tdraftRepo: draftRepo,\n\t\taccountRepo: accountRepo,\n\t}\n}\n\n// DTO для ответа\ntype PhotoWithStatus struct {\n\tphotos.ReceiptPhoto\n\tStatus photos.PhotoStatus `json:\"status\"`\n\tCanDelete bool `json:\"can_delete\"`\n\tCanRegenerate bool `json:\"can_regenerate\"`\n}\n\nfunc (s *Service) GetPhotosForServer(userID uuid.UUID, page, limit int) ([]PhotoWithStatus, int64, error) {\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\treturn nil, 0, errors.New(\"active server not found\")\n\t}\n\n\titems, total, err := s.photoRepo.GetByServerID(server.ID, page, limit)\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\tresult := make([]PhotoWithStatus, len(items))\n\tfor i, photo := range items {\n\t\tstatus := photos.PhotoStatusOrphan\n\t\tif photo.InvoiceID != nil {\n\t\t\tstatus = photos.PhotoStatusHasInvoice\n\t\t} else if photo.DraftID != nil {\n\t\t\tstatus = photos.PhotoStatusHasDraft\n\t\t}\n\n\t\tresult[i] = PhotoWithStatus{\n\t\t\tReceiptPhoto: photo,\n\t\t\tStatus: status,\n\t\t\t// Удалить можно только если нет накладной\n\t\t\tCanDelete: photo.InvoiceID == nil,\n\t\t\t// Пересоздать можно только если это \"сирота\"\n\t\t\tCanRegenerate: photo.DraftID == nil && photo.InvoiceID == nil,\n\t\t}\n\t}\n\n\treturn result, total, nil\n}\n\nfunc (s *Service) DeletePhoto(userID, photoID uuid.UUID, forceDeleteDraft bool) error {\n\t// Проверка прав\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\treturn errors.New(\"no active server\")\n\t}\n\n\t// Операторы не могут удалять фото из архива (только админы)\n\trole, _ := s.accountRepo.GetUserRole(userID, server.ID)\n\tif role == account.RoleOperator {\n\t\treturn errors.New(\"access denied\")\n\t}\n\n\tphoto, err := s.photoRepo.GetByID(photoID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Проверка: есть накладная\n\tif photo.InvoiceID != nil {\n\t\treturn ErrPhotoHasInvoice\n\t}\n\n\t// Проверка: есть черновик\n\tif photo.DraftID != nil {\n\t\tif !forceDeleteDraft {\n\t\t\treturn ErrPhotoHasDraft\n\t\t}\n\t\t// Если форсируем удаление - удаляем и черновик\n\t\tif err := s.draftRepo.Delete(*photo.DraftID); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// Удаляем файл с диска (физически)\n\t// В продакшене лучше делать это асинхронно или не делать вовсе (soft delete),\n\t// но для экономии места удалим.\n\t// Путь в БД может быть относительным или абсолютным, зависит от реализации загрузки.\n\t// В ocr/service мы пишем абсолютный путь.\n\t// Но в поле FilePath у нас путь.\n\tif photo.FilePath != \"\" {\n\t\t_ = os.Remove(photo.FilePath)\n\t}\n\n\treturn s.photoRepo.Delete(photoID)\n}\n", + "internal/services/recommend/service.go": "package recommend\n\nimport (\n\t\"go.uber.org/zap\"\n\n\t\"rmser/internal/domain/recommendations\"\n\t\"rmser/pkg/logger\"\n)\n\nconst (\n\tAnalyzeDaysNoIncoming = 90 // Ищем ингредиенты без закупок за 30 дней\n\tAnalyzeDaysStale = 90 // Ищем неликвид за 60 дней\n)\n\ntype Service struct {\n\trepo recommendations.Repository\n}\n\nfunc NewService(repo recommendations.Repository) *Service {\n\treturn &Service{repo: repo}\n}\n\n// RefreshRecommendations выполняет анализ и сохраняет результаты в БД\nfunc (s *Service) RefreshRecommendations() error {\n\tlogger.Log.Info(\"Запуск пересчета рекомендаций...\")\n\n\tvar all []recommendations.Recommendation\n\n\t// 1. Unused\n\tif unused, err := s.repo.FindUnusedGoods(); err == nil {\n\t\tall = append(all, unused...)\n\t} else {\n\t\tlogger.Log.Error(\"Ошибка unused\", zap.Error(err))\n\t}\n\n\t// 2. Purchased but Unused\n\tif purchUnused, err := s.repo.FindPurchasedButUnused(AnalyzeDaysNoIncoming); err == nil {\n\t\tall = append(all, purchUnused...)\n\t} else {\n\t\tlogger.Log.Error(\"Ошибка purchased_unused\", zap.Error(err))\n\t}\n\n\t// 3. No Incoming (Ингредиенты без закупок)\n\tif noInc, err := s.repo.FindNoIncomingIngredients(AnalyzeDaysNoIncoming); err == nil {\n\t\tall = append(all, noInc...)\n\t} else {\n\t\tlogger.Log.Error(\"Ошибка no_incoming\", zap.Error(err))\n\t}\n\n\t// 4. Usage without Purchase (Расход без прихода) <-- НОВОЕ\n\tif usageNoPurch, err := s.repo.FindUsageWithoutPurchase(AnalyzeDaysNoIncoming); err == nil {\n\t\tall = append(all, usageNoPurch...)\n\t} else {\n\t\tlogger.Log.Error(\"Ошибка usage_no_purchase\", zap.Error(err))\n\t}\n\n\t// 5. Stale (Неликвид)\n\tif stale, err := s.repo.FindStaleGoods(AnalyzeDaysStale); err == nil {\n\t\tall = append(all, stale...)\n\t} else {\n\t\tlogger.Log.Error(\"Ошибка stale\", zap.Error(err))\n\t}\n\n\t// 6. Dish in Recipe\n\tif dishInRec, err := s.repo.FindDishesInRecipes(); err == nil {\n\t\tall = append(all, dishInRec...)\n\t} else {\n\t\tlogger.Log.Error(\"Ошибка dish_in_recipe\", zap.Error(err))\n\t}\n\n\t// Сохраняем\n\tif err := s.repo.SaveAll(all); err != nil {\n\t\treturn err\n\t}\n\n\tlogger.Log.Info(\"Рекомендации обновлены\", zap.Int(\"total_count\", len(all)))\n\treturn nil\n}\n\nfunc (s *Service) GetRecommendations() ([]recommendations.Recommendation, error) {\n\treturn s.repo.GetAll()\n}\n", + "internal/services/sync/service.go": "package sync\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/shopspring/decimal\"\n\t\"go.uber.org/zap\"\n\n\t\"rmser/internal/domain/account\"\n\t\"rmser/internal/domain/catalog\"\n\t\"rmser/internal/domain/invoices\"\n\t\"rmser/internal/domain/operations\"\n\t\"rmser/internal/domain/recipes\"\n\t\"rmser/internal/domain/suppliers\"\n\t\"rmser/internal/infrastructure/rms\"\n\t\"rmser/pkg/logger\"\n)\n\nconst (\n\tPresetPurchases = \"1a3297e1-cb05-55dc-98a7-c13f13bc85a7\" // Закупки\n\tPresetUsage = \"24d9402e-2d01-eca1-ebeb-7981f7d1cb86\" // Расход\n)\n\ntype Service struct {\n\trmsFactory *rms.Factory\n\taccountRepo account.Repository\n\tcatalogRepo catalog.Repository\n\trecipeRepo recipes.Repository\n\tinvoiceRepo invoices.Repository\n\topRepo operations.Repository\n\tsupplierRepo suppliers.Repository\n}\n\nfunc NewService(\n\trmsFactory *rms.Factory,\n\taccountRepo account.Repository,\n\tcatalogRepo catalog.Repository,\n\trecipeRepo recipes.Repository,\n\tinvoiceRepo invoices.Repository,\n\topRepo operations.Repository,\n\tsupplierRepo suppliers.Repository,\n) *Service {\n\treturn &Service{\n\t\trmsFactory: rmsFactory,\n\t\taccountRepo: accountRepo,\n\t\tcatalogRepo: catalogRepo,\n\t\trecipeRepo: recipeRepo,\n\t\tinvoiceRepo: invoiceRepo,\n\t\topRepo: opRepo,\n\t\tsupplierRepo: supplierRepo,\n\t}\n}\n\n// SyncAllData запускает полную синхронизацию для конкретного пользователя\nfunc (s *Service) SyncAllData(userID uuid.UUID, force bool) error {\n\tlogger.Log.Info(\"Запуск синхронизации\", zap.String(\"user_id\", userID.String()), zap.Bool(\"force\", force))\n\n\t// 1. Получаем клиент и инфо о сервере\n\tclient, err := s.rmsFactory.GetClientForUser(userID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\treturn fmt.Errorf(\"active server not found for user %s\", userID)\n\t}\n\tserverID := server.ID\n\n\t// 2. Справочники\n\tif err := s.syncStores(client, serverID); err != nil {\n\t\tlogger.Log.Error(\"Sync Stores failed\", zap.Error(err))\n\t}\n\tif err := s.syncMeasureUnits(client, serverID); err != nil {\n\t\tlogger.Log.Error(\"Sync Units failed\", zap.Error(err))\n\t}\n\n\t// 3. Поставщики\n\tif err := s.syncSuppliers(client, serverID); err != nil {\n\t\tlogger.Log.Error(\"Sync Suppliers failed\", zap.Error(err))\n\t}\n\n\t// 4. Товары\n\tif err := s.syncProducts(client, serverID); err != nil {\n\t\tlogger.Log.Error(\"Sync Products failed\", zap.Error(err))\n\t}\n\n\t// 5. Техкарты (тяжелый запрос)\n\tif err := s.syncRecipes(client, serverID); err != nil {\n\t\tlogger.Log.Error(\"Sync Recipes failed\", zap.Error(err))\n\t}\n\n\t// 6. Накладные (история)\n\tif err := s.syncInvoices(client, serverID, force); err != nil {\n\t\tlogger.Log.Error(\"Sync Invoices failed\", zap.Error(err))\n\t}\n\n\t// 7. Складские операции (тяжелый запрос)\n\t// Для MVP можно отключить, если долго грузится\n\t// if err := s.SyncStoreOperations(client, serverID); err != nil {\n\t// \tlogger.Log.Error(\"Sync Operations failed\", zap.Error(err))\n\t// }\n\n\tlogger.Log.Info(\"Синхронизация завершена\", zap.String(\"user_id\", userID.String()))\n\treturn nil\n}\n\n// SyncInvoicesOnly запускает синхронизацию только накладных для конкретного пользователя\nfunc (s *Service) SyncInvoicesOnly(userID uuid.UUID) error {\n\tlogger.Log.Info(\"Запуск синхронизации накладных\", zap.String(\"user_id\", userID.String()))\n\n\t// Получаем клиент и инфо о сервере\n\tclient, err := s.rmsFactory.GetClientForUser(userID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\treturn fmt.Errorf(\"active server not found for user %s\", userID)\n\t}\n\tserverID := server.ID\n\n\t// Синхронизация накладных\n\tif err := s.syncInvoices(client, serverID, false); err != nil {\n\t\tlogger.Log.Error(\"Sync Invoices failed\", zap.Error(err))\n\t\treturn err\n\t}\n\n\tlogger.Log.Info(\"Синхронизация накладных завершена\", zap.String(\"user_id\", userID.String()))\n\treturn nil\n}\n\nfunc (s *Service) syncSuppliers(c rms.ClientI, serverID uuid.UUID) error {\n\tlist, err := c.FetchSuppliers()\n\tif err != nil {\n\t\treturn err\n\t}\n\t// Проставляем ServerID\n\tfor i := range list {\n\t\tlist[i].RMSServerID = serverID\n\t}\n\treturn s.supplierRepo.SaveBatch(list)\n}\n\nfunc (s *Service) syncStores(c rms.ClientI, serverID uuid.UUID) error {\n\tstores, err := c.FetchStores()\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor i := range stores {\n\t\tstores[i].RMSServerID = serverID\n\t}\n\treturn s.catalogRepo.SaveStores(stores)\n}\n\nfunc (s *Service) syncMeasureUnits(c rms.ClientI, serverID uuid.UUID) error {\n\tunits, err := c.FetchMeasureUnits()\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor i := range units {\n\t\tunits[i].RMSServerID = serverID\n\t}\n\treturn s.catalogRepo.SaveMeasureUnits(units)\n}\n\nfunc (s *Service) syncProducts(c rms.ClientI, serverID uuid.UUID) error {\n\tproducts, err := c.FetchCatalog()\n\tif err != nil {\n\t\treturn err\n\t}\n\t// Важно: Проставляем ID рекурсивно и в фасовки\n\tfor i := range products {\n\t\tproducts[i].RMSServerID = serverID\n\t\tfor j := range products[i].Containers {\n\t\t\tproducts[i].Containers[j].RMSServerID = serverID\n\t\t}\n\t}\n\treturn s.catalogRepo.SaveProducts(products)\n}\n\nfunc (s *Service) syncRecipes(c rms.ClientI, serverID uuid.UUID) error {\n\tdateFrom := time.Now().AddDate(0, -3, 0) // За 3 месяца\n\tdateTo := time.Now()\n\trecipesList, err := c.FetchRecipes(dateFrom, dateTo)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor i := range recipesList {\n\t\trecipesList[i].RMSServerID = serverID\n\t\tfor j := range recipesList[i].Items {\n\t\t\trecipesList[i].Items[j].RMSServerID = serverID\n\t\t}\n\t}\n\treturn s.recipeRepo.SaveRecipes(recipesList)\n}\n\nfunc (s *Service) syncInvoices(c rms.ClientI, serverID uuid.UUID, force bool) error {\n\tvar from time.Time\n\tto := time.Now()\n\n\tif force {\n\t\t// Принудительная перезагрузка за последние 40 дней\n\t\tfrom = time.Now().AddDate(0, 0, -40)\n\t\tlogger.Log.Info(\"Force sync invoices\", zap.String(\"from\", from.Format(\"2006-01-02\")))\n\t} else {\n\t\tlastDate, err := s.invoiceRepo.GetLastInvoiceDate(serverID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif lastDate != nil {\n\t\t\tfrom = *lastDate\n\t\t} else {\n\t\t\tfrom = time.Now().AddDate(0, 0, -45)\n\t\t}\n\t}\n\n\tinvs, err := c.FetchInvoices(from, to)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor i := range invs {\n\t\tinvs[i].RMSServerID = serverID\n\t}\n\n\tif len(invs) > 0 {\n\t\t// Репозиторий использует OnConflict(UpdateAll), поэтому существующие записи обновятся\n\t\treturn s.invoiceRepo.SaveInvoices(invs)\n\t}\n\treturn nil\n}\n\n// SyncStoreOperations публичный, если нужно вызывать отдельно\nfunc (s *Service) SyncStoreOperations(c rms.ClientI, serverID uuid.UUID) error {\n\tdateTo := time.Now()\n\tdateFrom := dateTo.AddDate(0, 0, -30)\n\n\tif err := s.syncReport(c, serverID, PresetPurchases, operations.OpTypePurchase, dateFrom, dateTo); err != nil {\n\t\treturn fmt.Errorf(\"purchases sync error: %w\", err)\n\t}\n\tif err := s.syncReport(c, serverID, PresetUsage, operations.OpTypeUsage, dateFrom, dateTo); err != nil {\n\t\treturn fmt.Errorf(\"usage sync error: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc (s *Service) syncReport(c rms.ClientI, serverID uuid.UUID, presetID string, targetOpType operations.OperationType, from, to time.Time) error {\n\titems, err := c.FetchStoreOperations(presetID, from, to)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tvar ops []operations.StoreOperation\n\tfor _, item := range items {\n\t\tpID, err := uuid.Parse(item.ProductID)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\trealOpType := classifyOperation(item.DocumentType)\n\t\tif realOpType == operations.OpTypeUnknown || realOpType != targetOpType {\n\t\t\tcontinue\n\t\t}\n\n\t\tops = append(ops, operations.StoreOperation{\n\t\t\tRMSServerID: serverID,\n\t\t\tProductID: pID,\n\t\t\tOpType: realOpType,\n\t\t\tDocumentType: item.DocumentType,\n\t\t\tTransactionType: item.TransactionType,\n\t\t\tDocumentNumber: item.DocumentNum,\n\t\t\tAmount: decimal.NewFromFloat(item.Amount),\n\t\t\tSum: decimal.NewFromFloat(item.Sum),\n\t\t\tCost: decimal.NewFromFloat(item.Cost),\n\t\t\tPeriodFrom: from,\n\t\t\tPeriodTo: to,\n\t\t})\n\t}\n\n\treturn s.opRepo.SaveOperations(ops, serverID, targetOpType, from, to)\n}\n\nfunc classifyOperation(docType string) operations.OperationType {\n\tswitch docType {\n\tcase \"INCOMING_INVOICE\", \"INCOMING_SERVICE\":\n\t\treturn operations.OpTypePurchase\n\tcase \"SALES_DOCUMENT\", \"WRITEOFF_DOCUMENT\", \"OUTGOING_INVOICE\", \"SESSION_ACCEPTANCE\", \"DISASSEMBLE_DOCUMENT\":\n\t\treturn operations.OpTypeUsage\n\tdefault:\n\t\treturn operations.OpTypeUnknown\n\t}\n}\n\n// Добавляем структуру для возврата статистики\ntype SyncStats struct {\n\tServerName string\n\tProductsCount int64\n\tStoresCount int64\n\tSuppliersCount int64\n\tInvoicesLast30 int64\n\tLastInvoice *time.Time\n}\n\n// GetSyncStats собирает информацию о данных текущего сервера\nfunc (s *Service) GetSyncStats(userID uuid.UUID) (*SyncStats, error) {\n\tserver, err := s.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\treturn nil, fmt.Errorf(\"нет активного сервера\")\n\t}\n\n\tstats := &SyncStats{\n\t\tServerName: server.Name,\n\t}\n\n\t// Параллельный запуск не обязателен, запросы Count очень быстрые\n\tif cnt, err := s.catalogRepo.CountGoods(server.ID); err == nil {\n\t\tstats.ProductsCount = cnt\n\t}\n\n\tif cnt, err := s.catalogRepo.CountStores(server.ID); err == nil {\n\t\tstats.StoresCount = cnt\n\t}\n\n\tif cnt, err := s.supplierRepo.Count(server.ID); err == nil {\n\t\tstats.SuppliersCount = cnt\n\t}\n\n\tif cnt, err := s.invoiceRepo.CountRecent(server.ID, 30); err == nil {\n\t\tstats.InvoicesLast30 = cnt\n\t}\n\n\tstats.LastInvoice, _ = s.invoiceRepo.GetLastInvoiceDate(server.ID)\n\n\treturn stats, nil\n}\n", + "internal/transport/http/handlers/billing.go": "package handlers\n\nimport (\n\t\"net/http\"\n\t\"rmser/internal/infrastructure/yookassa\"\n\t\"rmser/internal/services/billing\"\n\t\"rmser/pkg/logger\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"go.uber.org/zap\"\n)\n\ntype BillingHandler struct {\n\tservice *billing.Service\n}\n\nfunc NewBillingHandler(s *billing.Service) *BillingHandler {\n\treturn &BillingHandler{service: s}\n}\n\nfunc (h *BillingHandler) YooKassaWebhook(c *gin.Context) {\n\tvar event yookassa.WebhookEvent\n\tif err := c.ShouldBindJSON(&event); err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid json\"})\n\t\treturn\n\t}\n\n\tlogger.Log.Info(\"YooKassa Webhook received\",\n\t\tzap.String(\"event\", event.Event),\n\t\tzap.String(\"payment_id\", event.Object.ID),\n\t)\n\n\tif err := h.service.ProcessWebhook(c.Request.Context(), event); err != nil {\n\t\tlogger.Log.Error(\"Failed to process webhook\", zap.Error(err))\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"status\": \"error\"})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\"status\": \"ok\"})\n}\n", + "internal/transport/http/handlers/drafts.go": "package handlers\n\nimport (\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/google/uuid\"\n\t\"github.com/shopspring/decimal\"\n\t\"go.uber.org/zap\"\n\n\t\"rmser/internal/services/drafts\"\n\t\"rmser/pkg/logger\"\n)\n\ntype DraftsHandler struct {\n\tservice *drafts.Service\n}\n\nfunc NewDraftsHandler(service *drafts.Service) *DraftsHandler {\n\treturn &DraftsHandler{service: service}\n}\n\nfunc (h *DraftsHandler) GetDraft(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\tidStr := c.Param(\"id\")\n\tid, err := uuid.Parse(idStr)\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid id\"})\n\t\treturn\n\t}\n\n\tdraft, err := h.service.GetDraft(id, userID)\n\tif err != nil {\n\t\tc.JSON(http.StatusNotFound, gin.H{\"error\": \"draft not found\"})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, draft)\n}\n\nfunc (h *DraftsHandler) GetDictionaries(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\n\tdata, err := h.service.GetDictionaries(userID)\n\tif err != nil {\n\t\tlogger.Log.Error(\"GetDictionaries error\", zap.Error(err))\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, data)\n}\n\nfunc (h *DraftsHandler) GetStores(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\n\tdict, err := h.service.GetDictionaries(userID)\n\tif err != nil {\n\t\tlogger.Log.Error(\"GetStores error\", zap.Error(err))\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, dict[\"stores\"])\n}\n\n// UpdateItemDTO обновлен: float64 -> *float64, добавлен edited_field\ntype UpdateItemDTO struct {\n\tProductID *string `json:\"product_id\"`\n\tContainerID *string `json:\"container_id\"`\n\tQuantity *float64 `json:\"quantity\"`\n\tPrice *float64 `json:\"price\"`\n\tSum *float64 `json:\"sum\"`\n\tEditedField string `json:\"edited_field\"` // \"quantity\", \"price\", \"sum\"\n}\n\nfunc (h *DraftsHandler) AddDraftItem(c *gin.Context) {\n\tdraftID, err := uuid.Parse(c.Param(\"id\"))\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid draft id\"})\n\t\treturn\n\t}\n\n\titem, err := h.service.AddItem(draftID)\n\tif err != nil {\n\t\tlogger.Log.Error(\"Failed to add item\", zap.Error(err))\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, item)\n}\n\nfunc (h *DraftsHandler) DeleteDraftItem(c *gin.Context) {\n\tdraftID, err := uuid.Parse(c.Param(\"id\"))\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid draft id\"})\n\t\treturn\n\t}\n\titemID, err := uuid.Parse(c.Param(\"itemId\"))\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid item id\"})\n\t\treturn\n\t}\n\n\tnewTotal, err := h.service.DeleteItem(draftID, itemID)\n\tif err != nil {\n\t\tlogger.Log.Error(\"Failed to delete item\", zap.Error(err))\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"status\": \"deleted\",\n\t\t\"id\": itemID.String(),\n\t\t\"total_sum\": newTotal,\n\t})\n}\n\n// UpdateItem обновлен\nfunc (h *DraftsHandler) UpdateItem(c *gin.Context) {\n\tdraftID, _ := uuid.Parse(c.Param(\"id\"))\n\titemID, _ := uuid.Parse(c.Param(\"itemId\"))\n\n\tvar req UpdateItemDTO\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tvar pID *uuid.UUID\n\tif req.ProductID != nil && *req.ProductID != \"\" {\n\t\tif uid, err := uuid.Parse(*req.ProductID); err == nil {\n\t\t\tpID = &uid\n\t\t}\n\t}\n\n\tvar cID *uuid.UUID\n\tif req.ContainerID != nil {\n\t\tif *req.ContainerID == \"\" {\n\t\t\t// Сброс фасовки\n\t\t\tempty := uuid.Nil\n\t\t\tcID = &empty\n\t\t} else if uid, err := uuid.Parse(*req.ContainerID); err == nil {\n\t\t\tcID = &uid\n\t\t}\n\t}\n\n\tqty := decimal.Zero\n\tif req.Quantity != nil {\n\t\tqty = decimal.NewFromFloat(*req.Quantity)\n\t}\n\n\tprice := decimal.Zero\n\tif req.Price != nil {\n\t\tprice = decimal.NewFromFloat(*req.Price)\n\t}\n\n\tsum := decimal.Zero\n\tif req.Sum != nil {\n\t\tsum = decimal.NewFromFloat(*req.Sum)\n\t}\n\n\t// Дефолт, если фронт не прислал (для совместимости)\n\teditedField := req.EditedField\n\tif editedField == \"\" {\n\t\tif req.Sum != nil {\n\t\t\teditedField = \"sum\"\n\t\t} else if req.Price != nil {\n\t\t\teditedField = \"price\"\n\t\t} else {\n\t\t\teditedField = \"quantity\"\n\t\t}\n\t}\n\n\tif err := h.service.UpdateItem(draftID, itemID, pID, cID, qty, price, sum, editedField); err != nil {\n\t\tlogger.Log.Error(\"Failed to update item\", zap.Error(err))\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\"status\": \"updated\"})\n}\n\ntype CommitRequestDTO struct {\n\tDateIncoming string `json:\"date_incoming\"` // YYYY-MM-DD\n\tStoreID string `json:\"store_id\"`\n\tSupplierID string `json:\"supplier_id\"`\n\tComment string `json:\"comment\"`\n\tIncomingDocNum string `json:\"incoming_doc_num\"`\n}\n\nfunc (h *DraftsHandler) CommitDraft(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\tdraftID, err := uuid.Parse(c.Param(\"id\"))\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid draft id\"})\n\t\treturn\n\t}\n\n\tvar req CommitRequestDTO\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tdate, err := time.Parse(\"2006-01-02\", req.DateIncoming)\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid date format\"})\n\t\treturn\n\t}\n\tstoreID, err := uuid.Parse(req.StoreID)\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid store id\"})\n\t\treturn\n\t}\n\tsupplierID, err := uuid.Parse(req.SupplierID)\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid supplier id\"})\n\t\treturn\n\t}\n\n\tif err := h.service.UpdateDraftHeader(draftID, &storeID, &supplierID, date, req.Comment, req.IncomingDocNum); err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"failed to update header: \" + err.Error()})\n\t\treturn\n\t}\n\n\tdocNum, err := h.service.CommitDraft(draftID, userID)\n\tif err != nil {\n\t\tlogger.Log.Error(\"Commit failed\", zap.Error(err))\n\t\tc.JSON(http.StatusBadGateway, gin.H{\"error\": \"RMS error: \" + err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\"status\": \"completed\", \"document_number\": docNum})\n}\n\ntype AddContainerRequestDTO struct {\n\tProductID string `json:\"product_id\" binding:\"required\"`\n\tName string `json:\"name\" binding:\"required\"`\n\tCount float64 `json:\"count\" binding:\"required,gt=0\"`\n}\n\nfunc (h *DraftsHandler) AddContainer(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\n\tvar req AddContainerRequestDTO\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tpID, err := uuid.Parse(req.ProductID)\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid product_id\"})\n\t\treturn\n\t}\n\n\tcountDec := decimal.NewFromFloat(req.Count)\n\n\tnewID, err := h.service.CreateProductContainer(userID, pID, req.Name, countDec)\n\tif err != nil {\n\t\tlogger.Log.Error(\"Failed to create container\", zap.Error(err))\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\"status\": \"created\", \"container_id\": newID.String()})\n}\n\nfunc (h *DraftsHandler) GetDrafts(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\n\tfromStr := c.DefaultQuery(\"from\", time.Now().AddDate(0, 0, -30).Format(\"2006-01-02\"))\n\ttoStr := c.DefaultQuery(\"to\", time.Now().Format(\"2006-01-02\"))\n\n\tfrom, _ := time.Parse(\"2006-01-02\", fromStr)\n\tto, _ := time.Parse(\"2006-01-02\", toStr)\n\tto = to.Add(23*time.Hour + 59*time.Minute + 59*time.Second)\n\n\tlist, err := h.service.GetUnifiedList(userID, from, to)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, list)\n}\n\nfunc (h *DraftsHandler) DeleteDraft(c *gin.Context) {\n\tidStr := c.Param(\"id\")\n\tid, err := uuid.Parse(idStr)\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid id\"})\n\t\treturn\n\t}\n\n\tnewStatus, err := h.service.DeleteDraft(id)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\"status\": newStatus, \"id\": id.String()})\n}\n", + "internal/transport/http/handlers/invoices.go": "package handlers\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/google/uuid\"\n\t\"go.uber.org/zap\"\n\n\t\"rmser/internal/services/drafts\"\n\tinvService \"rmser/internal/services/invoices\"\n\t\"rmser/internal/services/sync\"\n\t\"rmser/pkg/logger\"\n)\n\ntype InvoiceHandler struct {\n\tservice *invService.Service\n\tdraftsService *drafts.Service\n\tsyncService *sync.Service\n}\n\nfunc NewInvoiceHandler(service *invService.Service, syncService *sync.Service) *InvoiceHandler {\n\treturn &InvoiceHandler{service: service, syncService: syncService}\n}\n\n// SendInvoice godoc\n// @Summary Создать приходную накладную в iikoRMS\n// @Description Принимает JSON с данными накладной и отправляет их в iiko\n// @Tags invoices\n// @Accept json\n// @Produce json\n// @Param input body invService.CreateRequestDTO true \"Invoice Data\"\n// @Success 200 {object} map[string]string \"created_number\"\n// @Failure 400 {object} map[string]string\n// @Failure 500 {object} map[string]string\nfunc (h *InvoiceHandler) SendInvoice(c *gin.Context) {\n\tvar req invService.CreateRequestDTO\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"Неверный формат JSON: \" + err.Error()})\n\t\treturn\n\t}\n\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\tdocNum, err := h.service.SendInvoiceToRMS(req, userID)\n\tif err != nil {\n\t\tlogger.Log.Error(\"Ошибка отправки накладной\", zap.Error(err))\n\t\t// Возвращаем 502 Bad Gateway, т.к. ошибка скорее всего на стороне RMS\n\t\tc.JSON(http.StatusBadGateway, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"status\": \"ok\",\n\t\t\"created_number\": docNum,\n\t})\n}\n\n// GetInvoice godoc\n// @Summary Получить детали накладной\n// @Description Возвращает детали синхронизированной накладной по ID\n// @Tags invoices\n// @Produce json\n// @Param id path string true \"Invoice ID\"\n// @Success 200 {object} invService.InvoiceDetailsDTO\n// @Failure 400 {object} map[string]string\n// @Failure 404 {object} map[string]string\n// @Failure 500 {object} map[string]string\nfunc (h *InvoiceHandler) GetInvoice(c *gin.Context) {\n\tid := c.Param(\"id\")\n\tif id == \"\" {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"ID накладной обязателен\"})\n\t\treturn\n\t}\n\n\tuuidID, err := uuid.Parse(id)\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"Неверный формат ID\"})\n\t\treturn\n\t}\n\n\tdto, err := h.service.GetInvoice(uuidID)\n\tif err != nil {\n\t\tlogger.Log.Error(\"Ошибка получения накладной\", zap.Error(err), zap.String(\"id\", id))\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"Ошибка получения накладной\"})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, dto)\n}\n\n// SyncInvoices godoc\n// @Summary Запустить синхронизацию накладных\n// @Description Запускает синхронизацию накладных для пользователя\n// @Tags invoices\n// @Produce json\n// @Success 200 {object} map[string]string\n// @Failure 500 {object} map[string]string\nfunc (h *InvoiceHandler) SyncInvoices(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\n\terr := h.syncService.SyncInvoicesOnly(userID)\n\tif err != nil {\n\t\tlogger.Log.Error(\"Ошибка синхронизации накладных\", zap.Error(err))\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"Ошибка синхронизации\"})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"status\": \"ok\",\n\t\t\"message\": \"Синхронизация запущена\",\n\t})\n}\n", + "internal/transport/http/handlers/ocr.go": "package handlers\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/google/uuid\"\n\t\"github.com/shopspring/decimal\"\n\t\"go.uber.org/zap\"\n\n\tocrService \"rmser/internal/services/ocr\"\n\t\"rmser/pkg/logger\"\n)\n\ntype OCRHandler struct {\n\tservice *ocrService.Service\n}\n\nfunc NewOCRHandler(service *ocrService.Service) *OCRHandler {\n\treturn &OCRHandler{service: service}\n}\n\n// GetCatalog возвращает список товаров для OCR сервиса\nfunc (h *OCRHandler) GetCatalog(c *gin.Context) {\n\t// Если этот эндпоинт дергает Python-скрипт без токена пользователя - это проблема безопасности.\n\t// Либо Python скрипт должен передавать токен админа/системы и ID сервера в query.\n\t// ПОКА: Предполагаем, что запрос идет от фронта или с заголовком X-Telegram-User-ID.\n\n\t// Если заголовка нет (вызов от скрипта), пробуем взять server_id из query (небезопасно, но для MVP)\n\t// Или лучше так: этот метод вызывается Фронтендом для поиска? Нет, название GetCatalogForIndexing намекает на OCR.\n\t// Оставим пока требование UserID.\n\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\n\titems, err := h.service.GetCatalogForIndexing(userID)\n\tif err != nil {\n\t\tlogger.Log.Error(\"Ошибка получения каталога\", zap.Error(err))\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, items)\n}\n\ntype MatchRequest struct {\n\tRawName string `json:\"raw_name\" binding:\"required\"`\n\tProductID string `json:\"product_id\" binding:\"required\"`\n\tQuantity float64 `json:\"quantity\"`\n\tContainerID *string `json:\"container_id\"`\n}\n\nfunc (h *OCRHandler) SaveMatch(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\n\tvar req MatchRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tpID, err := uuid.Parse(req.ProductID)\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid product_id format\"})\n\t\treturn\n\t}\n\n\tqty := decimal.NewFromFloat(1.0)\n\tif req.Quantity > 0 {\n\t\tqty = decimal.NewFromFloat(req.Quantity)\n\t}\n\n\tvar contID *uuid.UUID\n\tif req.ContainerID != nil && *req.ContainerID != \"\" {\n\t\tif uid, err := uuid.Parse(*req.ContainerID); err == nil {\n\t\t\tcontID = &uid\n\t\t}\n\t}\n\n\tif err := h.service.SaveMapping(userID, req.RawName, pID, qty, contID); err != nil {\n\t\tlogger.Log.Error(\"Ошибка сохранения матчинга\", zap.Error(err))\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\"status\": \"saved\"})\n}\n\nfunc (h *OCRHandler) DeleteMatch(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\trawName := c.Query(\"raw_name\")\n\n\tif rawName == \"\" {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"raw_name is required\"})\n\t\treturn\n\t}\n\n\tif err := h.service.DeleteMatch(userID, rawName); err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\"status\": \"deleted\"})\n}\n\nfunc (h *OCRHandler) SearchProducts(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\tquery := c.Query(\"q\")\n\n\tproducts, err := h.service.SearchProducts(userID, query)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, products)\n}\n\nfunc (h *OCRHandler) GetMatches(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\tmatches, err := h.service.GetKnownMatches(userID)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, matches)\n}\n\nfunc (h *OCRHandler) GetUnmatched(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\titems, err := h.service.GetUnmatchedItems(userID)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, items)\n}\n\nfunc (h *OCRHandler) DiscardUnmatched(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\trawName := c.Query(\"raw_name\")\n\n\tif rawName == \"\" {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"raw_name is required\"})\n\t\treturn\n\t}\n\n\tif err := h.service.DiscardUnmatched(userID, rawName); err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\"status\": \"discarded\"})\n}\n", + "internal/transport/http/handlers/photos.go": "package handlers\n\nimport (\n\t\"net/http\"\n\t\"strconv\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/google/uuid\"\n\n\tphotosSvc \"rmser/internal/services/photos\"\n)\n\ntype PhotosHandler struct {\n\tservice *photosSvc.Service\n}\n\nfunc NewPhotosHandler(service *photosSvc.Service) *PhotosHandler {\n\treturn &PhotosHandler{service: service}\n}\n\n// GetPhotos GET /api/photos\nfunc (h *PhotosHandler) GetPhotos(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\n\tpage, _ := strconv.Atoi(c.DefaultQuery(\"page\", \"1\"))\n\tlimit, _ := strconv.Atoi(c.DefaultQuery(\"limit\", \"20\"))\n\n\tphotos, total, err := h.service.GetPhotosForServer(userID, page, limit)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"photos\": photos,\n\t\t\"total\": total,\n\t\t\"page\": page,\n\t\t\"limit\": limit,\n\t})\n}\n\n// DeletePhoto DELETE /api/photos/:id\nfunc (h *PhotosHandler) DeletePhoto(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\tphotoID, err := uuid.Parse(c.Param(\"id\"))\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid id\"})\n\t\treturn\n\t}\n\n\tforceDeleteDraft := c.Query(\"force\") == \"true\"\n\n\terr = h.service.DeletePhoto(userID, photoID, forceDeleteDraft)\n\tif err != nil {\n\t\tif err == photosSvc.ErrPhotoHasDraft {\n\t\t\t// Специальный статус, чтобы фронт показал Confirm\n\t\t\tc.JSON(http.StatusConflict, gin.H{\n\t\t\t\t\"error\": err.Error(),\n\t\t\t\t\"requires_confirm\": true,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\"status\": \"deleted\"})\n}\n", + "internal/transport/http/handlers/recommendations.go": "package handlers\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"go.uber.org/zap\"\n\n\t\"rmser/internal/services/recommend\"\n\t\"rmser/pkg/logger\"\n)\n\ntype RecommendationsHandler struct {\n\tservice *recommend.Service\n}\n\nfunc NewRecommendationsHandler(service *recommend.Service) *RecommendationsHandler {\n\treturn &RecommendationsHandler{service: service}\n}\n\n// GetRecommendations godoc\n// @Summary Получить список рекомендаций\n// @Description Возвращает сгенерированные рекомендации (проблемные зоны учета)\n// @Tags recommendations\n// @Produce json\n// @Success 200 {array} recommendations.Recommendation\n// @Failure 500 {object} map[string]string\nfunc (h *RecommendationsHandler) GetRecommendations(c *gin.Context) {\n\trecs, err := h.service.GetRecommendations()\n\tif err != nil {\n\t\tlogger.Log.Error(\"Ошибка получения рекомендаций\", zap.Error(err))\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, recs)\n}\n", + "internal/transport/http/handlers/settings.go": "package handlers\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/google/uuid\"\n\t\"go.uber.org/zap\"\n\n\t\"rmser/internal/domain/account\"\n\t\"rmser/internal/domain/catalog\"\n\t\"rmser/pkg/logger\"\n)\n\n// Notifier - интерфейс для отправки уведомлений (реализуется ботом)\ntype Notifier interface {\n\tSendRoleChangeNotification(telegramID int64, serverName string, newRole string)\n\tSendRemovalNotification(telegramID int64, serverName string)\n}\n\ntype SettingsHandler struct {\n\taccountRepo account.Repository\n\tcatalogRepo catalog.Repository\n\tnotifier Notifier // Поле для отправки уведомлений\n}\n\nfunc NewSettingsHandler(accRepo account.Repository, catRepo catalog.Repository) *SettingsHandler {\n\treturn &SettingsHandler{\n\t\taccountRepo: accRepo,\n\t\tcatalogRepo: catRepo,\n\t}\n}\n\n// SetNotifier используется для внедрения зависимости после инициализации\nfunc (h *SettingsHandler) SetNotifier(n Notifier) {\n\th.notifier = n\n}\n\n// SettingsResponse - DTO для отдачи настроек\ntype SettingsResponse struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tBaseURL string `json:\"base_url\"`\n\tDefaultStoreID *string `json:\"default_store_id\"` // Nullable\n\tRootGroupID *string `json:\"root_group_id\"` // Nullable\n\tAutoConduct bool `json:\"auto_conduct\"`\n\tRole string `json:\"role\"` // OWNER, ADMIN, OPERATOR\n}\n\n// GetSettings возвращает настройки активного сервера + роль пользователя\nfunc (h *SettingsHandler) GetSettings(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\n\tserver, err := h.accountRepo.GetActiveServer(userID)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\tif server == nil {\n\t\tc.JSON(http.StatusNotFound, gin.H{\"error\": \"No active server\"})\n\t\treturn\n\t}\n\n\trole, err := h.accountRepo.GetUserRole(userID, server.ID)\n\tif err != nil {\n\t\trole = account.RoleOperator\n\t}\n\n\tresp := SettingsResponse{\n\t\tID: server.ID.String(),\n\t\tName: server.Name,\n\t\tBaseURL: server.BaseURL,\n\t\tAutoConduct: server.AutoProcess,\n\t\tRole: string(role),\n\t}\n\n\tif server.DefaultStoreID != nil {\n\t\ts := server.DefaultStoreID.String()\n\t\tresp.DefaultStoreID = &s\n\t}\n\tif server.RootGroupGUID != nil {\n\t\ts := server.RootGroupGUID.String()\n\t\tresp.RootGroupID = &s\n\t}\n\n\tc.JSON(http.StatusOK, resp)\n}\n\n// UpdateSettingsDTO\ntype UpdateSettingsDTO struct {\n\tName string `json:\"name\"`\n\tDefaultStoreID string `json:\"default_store_id\"`\n\tRootGroupID string `json:\"root_group_id\"`\n\tAutoProcess bool `json:\"auto_process\"`\n\tAutoConduct bool `json:\"auto_conduct\"`\n}\n\n// UpdateSettings сохраняет настройки\nfunc (h *SettingsHandler) UpdateSettings(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\n\tvar req UpdateSettingsDTO\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tserver, err := h.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\tc.JSON(http.StatusNotFound, gin.H{\"error\": \"No active server\"})\n\t\treturn\n\t}\n\n\t// ПРОВЕРКА ПРАВ\n\trole, err := h.accountRepo.GetUserRole(userID, server.ID)\n\tif err != nil {\n\t\tc.JSON(http.StatusForbidden, gin.H{\"error\": \"Access check failed\"})\n\t\treturn\n\t}\n\tif role != account.RoleOwner && role != account.RoleAdmin {\n\t\tc.JSON(http.StatusForbidden, gin.H{\"error\": \"У вас нет прав на изменение настроек сервера (требуется ADMIN или OWNER)\"})\n\t\treturn\n\t}\n\n\tif req.Name != \"\" {\n\t\tserver.Name = req.Name\n\t}\n\n\tif req.AutoConduct {\n\t\tserver.AutoProcess = true\n\t} else {\n\t\tserver.AutoProcess = req.AutoProcess || req.AutoConduct\n\t}\n\n\tif req.DefaultStoreID != \"\" {\n\t\tif uid, err := uuid.Parse(req.DefaultStoreID); err == nil {\n\t\t\tserver.DefaultStoreID = &uid\n\t\t}\n\t} else {\n\t\tserver.DefaultStoreID = nil\n\t}\n\n\tif req.RootGroupID != \"\" {\n\t\tif uid, err := uuid.Parse(req.RootGroupID); err == nil {\n\t\t\tserver.RootGroupGUID = &uid\n\t\t}\n\t} else {\n\t\tserver.RootGroupGUID = nil\n\t}\n\n\tif err := h.accountRepo.SaveServerSettings(server); err != nil {\n\t\tlogger.Log.Error(\"Failed to save settings\", zap.Error(err))\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\th.GetSettings(c)\n}\n\n// --- Group Tree Logic ---\n\ntype GroupNode struct {\n\tKey string `json:\"key\"`\n\tValue string `json:\"value\"`\n\tTitle string `json:\"title\"`\n\tChildren []*GroupNode `json:\"children\"`\n}\n\nfunc (h *SettingsHandler) GetGroupsTree(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\tserver, err := h.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\tc.JSON(http.StatusNotFound, gin.H{\"error\": \"No active server\"})\n\t\treturn\n\t}\n\n\tgroups, err := h.catalogRepo.GetGroups(server.ID)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\ttree := buildTree(groups)\n\tc.JSON(http.StatusOK, tree)\n}\n\nfunc buildTree(flat []catalog.Product) []*GroupNode {\n\tnodeMap := make(map[uuid.UUID]*GroupNode)\n\tfor _, g := range flat {\n\t\tnodeMap[g.ID] = &GroupNode{\n\t\t\tKey: g.ID.String(),\n\t\t\tValue: g.ID.String(),\n\t\t\tTitle: g.Name,\n\t\t\tChildren: make([]*GroupNode, 0),\n\t\t}\n\t}\n\n\tvar roots []*GroupNode\n\tfor _, g := range flat {\n\t\tnode := nodeMap[g.ID]\n\t\tif g.ParentID != nil {\n\t\t\tif parent, exists := nodeMap[*g.ParentID]; exists {\n\t\t\t\tparent.Children = append(parent.Children, node)\n\t\t\t} else {\n\t\t\t\troots = append(roots, node)\n\t\t\t}\n\t\t} else {\n\t\t\troots = append(roots, node)\n\t\t}\n\t}\n\treturn roots\n}\n\n// --- User Management ---\n\nfunc (h *SettingsHandler) GetServerUsers(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\n\tserver, err := h.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\tc.JSON(http.StatusNotFound, gin.H{\"error\": \"No active server\"})\n\t\treturn\n\t}\n\n\tmyRole, err := h.accountRepo.GetUserRole(userID, server.ID)\n\tif err != nil || (myRole != account.RoleOwner && myRole != account.RoleAdmin) {\n\t\tc.JSON(http.StatusForbidden, gin.H{\"error\": \"Access denied\"})\n\t\treturn\n\t}\n\n\tusers, err := h.accountRepo.GetServerUsers(server.ID)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\ttype UserDTO struct {\n\t\tUserID uuid.UUID `json:\"user_id\"`\n\t\tUsername string `json:\"username\"`\n\t\tFirstName string `json:\"first_name\"`\n\t\tLastName string `json:\"last_name\"`\n\t\tPhotoURL string `json:\"photo_url\"`\n\t\tRole account.Role `json:\"role\"`\n\t\tIsMe bool `json:\"is_me\"`\n\t}\n\n\tresponse := make([]UserDTO, 0, len(users))\n\tfor _, u := range users {\n\t\tresponse = append(response, UserDTO{\n\t\t\tUserID: u.UserID,\n\t\t\tUsername: u.User.Username,\n\t\t\tFirstName: u.User.FirstName,\n\t\t\tLastName: u.User.LastName,\n\t\t\tPhotoURL: u.User.PhotoURL,\n\t\t\tRole: u.Role,\n\t\t\tIsMe: u.UserID == userID,\n\t\t})\n\t}\n\n\tc.JSON(http.StatusOK, response)\n}\n\ntype UpdateUserRoleDTO struct {\n\tNewRole string `json:\"new_role\" binding:\"required\"` // ADMIN, OPERATOR\n}\n\nfunc (h *SettingsHandler) UpdateUserRole(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\ttargetUserID, err := uuid.Parse(c.Param(\"userId\"))\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid target user id\"})\n\t\treturn\n\t}\n\n\tvar req UpdateUserRoleDTO\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tnewRole := account.Role(req.NewRole)\n\tif newRole != account.RoleAdmin && newRole != account.RoleOperator {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid role (allowed: ADMIN, OPERATOR)\"})\n\t\treturn\n\t}\n\n\tserver, err := h.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\tc.JSON(http.StatusNotFound, gin.H{\"error\": \"No active server\"})\n\t\treturn\n\t}\n\n\tmyRole, _ := h.accountRepo.GetUserRole(userID, server.ID)\n\tif myRole != account.RoleOwner {\n\t\tc.JSON(http.StatusForbidden, gin.H{\"error\": \"Only OWNER can change roles\"})\n\t\treturn\n\t}\n\n\tif userID == targetUserID {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"Cannot change own role\"})\n\t\treturn\n\t}\n\n\tif err := h.accountRepo.SetUserRole(server.ID, targetUserID, newRole); err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\t// --- УВЕДОМЛЕНИЕ О СМЕНЕ РОЛИ ---\n\tif h.notifier != nil {\n\t\tgo func() {\n\t\t\tusers, err := h.accountRepo.GetServerUsers(server.ID)\n\t\t\tif err == nil {\n\t\t\t\tfor _, u := range users {\n\t\t\t\t\tif u.UserID == targetUserID {\n\t\t\t\t\t\th.notifier.SendRoleChangeNotification(u.User.TelegramID, server.Name, string(newRole))\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}()\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\"status\": \"updated\"})\n}\n\nfunc (h *SettingsHandler) RemoveUser(c *gin.Context) {\n\tuserID := c.MustGet(\"userID\").(uuid.UUID)\n\ttargetUserID, err := uuid.Parse(c.Param(\"userId\"))\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid target user id\"})\n\t\treturn\n\t}\n\n\tserver, err := h.accountRepo.GetActiveServer(userID)\n\tif err != nil || server == nil {\n\t\tc.JSON(http.StatusNotFound, gin.H{\"error\": \"No active server\"})\n\t\treturn\n\t}\n\n\tmyRole, _ := h.accountRepo.GetUserRole(userID, server.ID)\n\tif myRole != account.RoleOwner && myRole != account.RoleAdmin {\n\t\tc.JSON(http.StatusForbidden, gin.H{\"error\": \"Access denied\"})\n\t\treturn\n\t}\n\n\tif userID == targetUserID {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"Use 'leave' function instead\"})\n\t\treturn\n\t}\n\n\t// Ищем цель в списке, чтобы проверить права и получить TelegramID для уведомления\n\tusers, _ := h.accountRepo.GetServerUsers(server.ID)\n\tvar targetTgID int64\n\tvar found bool\n\n\tfor _, u := range users {\n\t\tif u.UserID == targetUserID {\n\t\t\tfound = true\n\t\t\ttargetTgID = u.User.TelegramID\n\n\t\t\tif u.Role == account.RoleOwner {\n\t\t\t\tc.JSON(http.StatusForbidden, gin.H{\"error\": \"Cannot remove Owner\"})\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif myRole == account.RoleAdmin && u.Role == account.RoleAdmin {\n\t\t\t\tc.JSON(http.StatusForbidden, gin.H{\"error\": \"Admins cannot remove other Admins\"})\n\t\t\t\treturn\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif !found {\n\t\tc.JSON(http.StatusNotFound, gin.H{\"error\": \"Target user not found on server\"})\n\t\treturn\n\t}\n\n\tif err := h.accountRepo.RemoveUserFromServer(server.ID, targetUserID); err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\t// --- УВЕДОМЛЕНИЕ ОБ УДАЛЕНИИ ---\n\tif h.notifier != nil && targetTgID != 0 {\n\t\tgo h.notifier.SendRemovalNotification(targetTgID, server.Name)\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\"status\": \"removed\"})\n}\n", + "internal/transport/http/middleware/auth.go": "package middleware\n\nimport (\n\t\"crypto/hmac\"\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"rmser/internal/domain/account\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\n// AuthMiddleware проверяет initData от Telegram\nfunc AuthMiddleware(accountRepo account.Repository, botToken string, maintenanceMode bool, devIDs []int64) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\t// 1. Извлекаем данные авторизации\n\t\tauthHeader := c.GetHeader(\"Authorization\")\n\t\tvar initData string\n\n\t\tif strings.HasPrefix(authHeader, \"Bearer \") {\n\t\t\tinitData = strings.TrimPrefix(authHeader, \"Bearer \")\n\t\t} else {\n\t\t\t// Оставляем лазейку для отладки ТОЛЬКО если это не production режим\n\t\t\t// В реальности лучше всегда требовать подпись\n\t\t\tinitData = c.Query(\"_auth\")\n\t\t}\n\n\t\tif initData == \"\" {\n\t\t\tc.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{\"error\": \"Авторизация отклонена: отсутствует подпись Telegram\"})\n\t\t\treturn\n\t\t}\n\n\t\t// 2. Проверяем подпись (HMAC-SHA256)\n\t\tisValid, err := verifyTelegramInitData(initData, botToken)\n\t\tif !isValid || err != nil {\n\t\t\tc.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{\"error\": \"Критическая ошибка безопасности: поддельная подпись\"})\n\t\t\treturn\n\t\t}\n\n\t\t// 3. Извлекаем User ID из проверенных данных\n\t\tvalues, _ := url.ParseQuery(initData)\n\t\tuserJSON := values.Get(\"user\")\n\n\t\t// Извлекаем id вручную из JSON-подобной строки или через простой парсинг\n\t\t// Telegram передает user как JSON-объект: {\"id\":12345,\"first_name\":\"...\"}\n\t\ttgID, err := extractIDFromUserJSON(userJSON)\n\t\tif err != nil {\n\t\t\tc.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{\"error\": \"Не удалось извлечь Telegram ID\"})\n\t\t\treturn\n\t\t}\n\n\t\t// Проверка режима обслуживания: если включен, разрешаем доступ только разработчикам\n\t\tif maintenanceMode {\n\t\t\tisDev := false\n\t\t\tfor _, devID := range devIDs {\n\t\t\t\tif tgID == devID {\n\t\t\t\t\tisDev = true\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif !isDev {\n\t\t\t\tc.AbortWithStatusJSON(503, gin.H{\"error\": \"maintenance_mode\", \"message\": \"Сервис на обслуживании\"})\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\t// 4. Ищем пользователя в БД\n\t\tuser, err := accountRepo.GetUserByTelegramID(tgID)\n\t\tif err != nil {\n\t\t\tc.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{\"error\": \"Пользователь не зарегистрирован. Начните диалог с ботом.\"})\n\t\t\treturn\n\t\t}\n\n\t\tc.Set(\"userID\", user.ID)\n\t\tc.Set(\"telegramID\", tgID)\n\t\tc.Next()\n\t}\n}\n\n// verifyTelegramInitData реализует алгоритм проверки Telegram\nfunc verifyTelegramInitData(initData, token string) (bool, error) {\n\tvalues, err := url.ParseQuery(initData)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\thash := values.Get(\"hash\")\n\tif hash == \"\" {\n\t\treturn false, fmt.Errorf(\"no hash found\")\n\t}\n\tvalues.Del(\"hash\")\n\n\t// Сортируем ключи\n\tkeys := make([]string, 0, len(values))\n\tfor k := range values {\n\t\tkeys = append(keys, k)\n\t}\n\tsort.Strings(keys)\n\n\t// Собираем data_check_string\n\tvar dataCheckArr []string\n\tfor _, k := range keys {\n\t\tdataCheckArr = append(dataCheckArr, fmt.Sprintf(\"%s=%s\", k, values.Get(k)))\n\t}\n\tdataCheckString := strings.Join(dataCheckArr, \"\\n\")\n\n\t// Вычисляем секретный ключ: HMAC-SHA256(\"WebAppData\", token)\n\tsha := sha256.New()\n\tsha.Write([]byte(token))\n\n\tsecretKey := hmac.New(sha256.New, []byte(\"WebAppData\"))\n\tsecretKey.Write([]byte(token))\n\n\t// Вычисляем финальный HMAC\n\th := hmac.New(sha256.New, secretKey.Sum(nil))\n\th.Write([]byte(dataCheckString))\n\texpectedHash := hex.EncodeToString(h.Sum(nil))\n\n\treturn expectedHash == hash, nil\n}\n\n// Упрощенное извлечение ID из JSON-строки поля user\nfunc extractIDFromUserJSON(userJSON string) (int64, error) {\n\t// Ищем \"id\":(\\d+)\n\t// Для надежности в будущем можно использовать json.Unmarshal\n\tstartIdx := strings.Index(userJSON, \"\\\"id\\\":\")\n\tif startIdx == -1 {\n\t\treturn 0, fmt.Errorf(\"id not found\")\n\t}\n\tstartIdx += 5\n\tendIdx := strings.IndexAny(userJSON[startIdx:], \",}\")\n\tif endIdx == -1 {\n\t\treturn 0, fmt.Errorf(\"invalid json\")\n\t}\n\n\tidStr := userJSON[startIdx : startIdx+endIdx]\n\treturn strconv.ParseInt(idStr, 10, 64)\n}\n", + "internal/transport/telegram/bot.go": "// internal/transport/telegram/bot.go\n\npackage telegram\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"go.uber.org/zap\"\n\ttele \"gopkg.in/telebot.v3\"\n\t\"gopkg.in/telebot.v3/middleware\"\n\n\t\"rmser/config\"\n\t\"rmser/internal/domain/account\"\n\t\"rmser/internal/infrastructure/rms\"\n\t\"rmser/internal/services/billing\"\n\t\"rmser/internal/services/ocr\"\n\t\"rmser/internal/services/sync\"\n\t\"rmser/pkg/crypto\"\n\t\"rmser/pkg/logger\"\n)\n\ntype Bot struct {\n\tb *tele.Bot\n\tocrService *ocr.Service\n\tsyncService *sync.Service\n\tbillingService *billing.Service\n\taccountRepo account.Repository\n\trmsFactory *rms.Factory\n\tcryptoManager *crypto.CryptoManager\n\n\tfsm *StateManager\n\tadminIDs map[int64]struct{}\n\tdevIDs map[int64]struct{}\n\tmaintenanceMode bool\n\twebAppURL string\n\n\tmenuServers *tele.ReplyMarkup\n\tmenuDicts *tele.ReplyMarkup\n\tmenuBalance *tele.ReplyMarkup\n}\n\nfunc NewBot(\n\tcfg config.TelegramConfig,\n\tocrService *ocr.Service,\n\tsyncService *sync.Service,\n\tbillingService *billing.Service,\n\taccountRepo account.Repository,\n\trmsFactory *rms.Factory,\n\tcryptoManager *crypto.CryptoManager,\n\tmaintenanceMode bool,\n\tdevIDs []int64,\n) (*Bot, error) {\n\n\tpref := tele.Settings{\n\t\tToken: cfg.Token,\n\t\tPoller: &tele.LongPoller{Timeout: 10 * time.Second},\n\t\tOnError: func(err error, c tele.Context) {\n\t\t\tlogger.Log.Error(\"Telegram error\", zap.Error(err))\n\t\t},\n\t}\n\n\tb, err := tele.NewBot(pref)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tadmins := make(map[int64]struct{})\n\tfor _, id := range cfg.AdminIDs {\n\t\tadmins[id] = struct{}{}\n\t}\n\n\tdevs := make(map[int64]struct{})\n\tfor _, id := range devIDs {\n\t\tdevs[id] = struct{}{}\n\t}\n\n\tbot := &Bot{\n\t\tb: b,\n\t\tocrService: ocrService,\n\t\tsyncService: syncService,\n\t\tbillingService: billingService,\n\t\taccountRepo: accountRepo,\n\t\trmsFactory: rmsFactory,\n\t\tcryptoManager: cryptoManager,\n\t\tfsm: NewStateManager(),\n\t\tadminIDs: admins,\n\t\tdevIDs: devs,\n\t\tmaintenanceMode: maintenanceMode,\n\t\twebAppURL: cfg.WebAppURL,\n\t}\n\n\tif bot.webAppURL == \"\" {\n\t\tbot.webAppURL = \"http://example.com\"\n\t}\n\n\tbot.initMenus()\n\tbot.initHandlers()\n\treturn bot, nil\n}\n\nfunc (bot *Bot) isDev(userID int64) bool {\n\t_, ok := bot.devIDs[userID]\n\treturn !bot.maintenanceMode || ok\n}\n\nfunc (bot *Bot) initMenus() {\n\tbot.menuServers = &tele.ReplyMarkup{}\n\n\tbot.menuDicts = &tele.ReplyMarkup{}\n\tbtnSync := bot.menuDicts.Data(\"⚡️ Быстрое обновление\", \"act_sync\")\n\tbtnFullSync := bot.menuDicts.Data(\"♻️ Полная перезагрузка\", \"act_full_sync\")\n\tbtnBack := bot.menuDicts.Data(\"🔙 Назад\", \"nav_main\")\n\tbot.menuDicts.Inline(\n\t\tbot.menuDicts.Row(btnSync),\n\t\tbot.menuDicts.Row(btnFullSync),\n\t\tbot.menuDicts.Row(btnBack),\n\t)\n\n\tbot.menuBalance = &tele.ReplyMarkup{}\n\t// Кнопки пополнения теперь создаются динамически в renderBalanceMenu\n}\n\nfunc (bot *Bot) initHandlers() {\n\tbot.b.Use(middleware.Logger())\n\tbot.b.Use(bot.registrationMiddleware)\n\n\tbot.b.Handle(\"/start\", bot.handleStartCommand)\n\tbot.b.Handle(\"/admin\", bot.handleAdminCommand)\n\n\tbot.b.Handle(&tele.Btn{Unique: \"nav_main\"}, bot.renderMainMenu)\n\tbot.b.Handle(&tele.Btn{Unique: \"nav_servers\"}, bot.renderServersMenu)\n\tbot.b.Handle(&tele.Btn{Unique: \"nav_dicts\"}, bot.renderDictsMenu)\n\tbot.b.Handle(&tele.Btn{Unique: \"nav_balance\"}, bot.renderBalanceMenu)\n\n\tbot.b.Handle(&tele.Btn{Unique: \"act_add_server\"}, bot.startAddServerFlow)\n\tbot.b.Handle(&tele.Btn{Unique: \"act_sync\"}, bot.triggerSync)\n\tbot.b.Handle(&tele.Btn{Unique: \"act_full_sync\"}, bot.triggerFullSync)\n\tbot.b.Handle(&tele.Btn{Unique: \"act_del_server_menu\"}, bot.renderDeleteServerMenu)\n\tbot.b.Handle(&tele.Btn{Unique: \"confirm_name_yes\"}, bot.handleConfirmNameYes)\n\tbot.b.Handle(&tele.Btn{Unique: \"confirm_name_no\"}, bot.handleConfirmNameNo)\n\n\tbot.b.Handle(&tele.Btn{Unique: \"adm_list_servers\"}, bot.adminListServers)\n\tbot.b.Handle(tele.OnCallback, bot.handleCallback)\n\n\tbot.b.Handle(tele.OnText, bot.handleText)\n\tbot.b.Handle(tele.OnPhoto, bot.handlePhoto)\n}\n\nfunc (bot *Bot) Start() {\n\tlogger.Log.Info(\"Запуск Telegram бота...\")\n\tbot.b.Start()\n}\n\nfunc (bot *Bot) Stop() { bot.b.Stop() }\n\nfunc (bot *Bot) registrationMiddleware(next tele.HandlerFunc) tele.HandlerFunc {\n\treturn func(c tele.Context) error {\n\t\tuser := c.Sender()\n\t\t_, err := bot.accountRepo.GetOrCreateUser(user.ID, user.Username, user.FirstName, user.LastName)\n\t\tif err != nil {\n\t\t\tlogger.Log.Error(\"Failed to register user\", zap.Error(err))\n\t\t}\n\t\treturn next(c)\n\t}\n}\n\nfunc (bot *Bot) handleStartCommand(c tele.Context) error {\n\tpayload := c.Message().Payload\n\tif payload != \"\" && strings.HasPrefix(payload, \"invite_\") {\n\t\treturn bot.handleInviteLink(c, strings.TrimPrefix(payload, \"invite_\"))\n\t}\n\n\tuserID := c.Sender().ID\n\tif bot.maintenanceMode && !bot.isDev(userID) {\n\t\treturn c.Send(\"🛠 **Сервис на техническом обслуживании** Мы проводим плановые работы... 📸 **Вы можете отправить фото чека или накладной** прямо в этот чат — наша команда обработает его и добавит в систему вручную.\", tele.ModeHTML)\n\t}\n\n\twelcomeTxt := \"🚀 RMSer — ваш умный ассистент для iiko\\n\\n\" +\n\t\t\"Больше не нужно вводить накладные вручную. Просто сфотографируйте чек, а я сделаю всё остальное.\\n\\n\" +\n\t\t\"Почему это удобно:\\n\" +\n\t\t\"🧠 Самообучение: Сопоставьте товар один раз, и в следующий раз я узнаю его сам.\\n\" +\n\t\t\"⚙️ Гибкая настройка: Укажите склад по умолчанию и ограничьте область поиска товаров только нужными категориями.\\n\" +\n\t\t\"👥 Работа в команде: Приглашайте сотрудников, распределяйте роли и управляйте доступом прямо в Mini App.\\n\\n\" +\n\t\t\"🎁 Старт без риска: Дарим 10 накладных на 30 дней каждому новому серверу!\"\n\n\treturn bot.renderMainMenuWithText(c, welcomeTxt)\n}\n\n// Вспомогательный метод для рендера (чтобы не дублировать код меню)\nfunc (bot *Bot) renderMainMenuWithText(c tele.Context, text string) error {\n\tbot.fsm.Reset(c.Sender().ID)\n\tuserDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)\n\tactiveServer, _ := bot.accountRepo.GetActiveServer(userDB.ID)\n\n\tmenu := &tele.ReplyMarkup{}\n\tbtnServers := menu.Data(\"🖥 Серверы\", \"nav_servers\")\n\tbtnDicts := menu.Data(\"🔄 Справочники\", \"nav_dicts\")\n\tbtnBalance := menu.Data(\"💰 Баланс\", \"nav_balance\")\n\n\tvar rows []tele.Row\n\trows = append(rows, menu.Row(btnServers, btnDicts))\n\trows = append(rows, menu.Row(btnBalance))\n\n\tif activeServer != nil {\n\t\trole, _ := bot.accountRepo.GetUserRole(userDB.ID, activeServer.ID)\n\t\tif role == account.RoleOwner || role == account.RoleAdmin {\n\t\t\tbtnApp := menu.WebApp(\"📱 Открыть приложение\", &tele.WebApp{URL: bot.webAppURL})\n\t\t\trows = append(rows, menu.Row(btnApp))\n\t\t}\n\t}\n\n\tmenu.Inline(rows...)\n\treturn c.EditOrSend(text, menu, tele.ModeHTML)\n}\n\nfunc (bot *Bot) handleInviteLink(c tele.Context, serverIDStr string) error {\n\tserverID, err := uuid.Parse(serverIDStr)\n\tif err != nil {\n\t\treturn c.Send(\"❌ Некорректная ссылка приглашения.\")\n\t}\n\n\tnewUser := c.Sender()\n\tuserDB, _ := bot.accountRepo.GetOrCreateUser(newUser.ID, newUser.Username, newUser.FirstName, newUser.LastName)\n\n\terr = bot.accountRepo.AddUserToServer(serverID, userDB.ID, account.RoleOperator)\n\tif err != nil {\n\t\treturn c.Send(fmt.Sprintf(\"❌ Не удалось подключиться к серверу: %v\", err))\n\t}\n\n\tbot.rmsFactory.ClearCacheForUser(userDB.ID)\n\n\tactiveServer, err := bot.accountRepo.GetActiveServer(userDB.ID)\n\tif err != nil || activeServer == nil || activeServer.ID != serverID {\n\t\treturn c.Send(\"✅ Доступ предоставлен, но сервер не стал активным автоматически. Выберите его в меню.\")\n\t}\n\n\trole, _ := bot.accountRepo.GetUserRole(userDB.ID, serverID)\n\n\tc.Send(fmt.Sprintf(\"✅ Вы подключены к серверу %s.\\nВаша роль: %s.\\nТеперь вы можете загружать чеки.\", activeServer.Name, role), tele.ModeHTML)\n\n\tif role != account.RoleOwner {\n\t\tgo func() {\n\t\t\tusers, err := bot.accountRepo.GetServerUsers(serverID)\n\t\t\tif err == nil {\n\t\t\t\tfor _, u := range users {\n\t\t\t\t\tif u.Role == account.RoleOwner {\n\t\t\t\t\t\tif u.UserID == userDB.ID {\n\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t}\n\t\t\t\t\t\tname := newUser.FirstName\n\t\t\t\t\t\tif newUser.LastName != \"\" {\n\t\t\t\t\t\t\tname += \" \" + newUser.LastName\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif newUser.Username != \"\" {\n\t\t\t\t\t\t\tname += fmt.Sprintf(\" (@%s)\", newUser.Username)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tmsg := fmt.Sprintf(\"🔔 Обновление команды\\n\\nПользователь %s активировал приглашение на сервер «%s» (Роль: %s).\", name, activeServer.Name, role)\n\t\t\t\t\t\tbot.b.Send(&tele.User{ID: u.User.TelegramID}, msg, tele.ModeHTML)\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}()\n\t}\n\n\treturn bot.renderMainMenu(c)\n}\n\nfunc (bot *Bot) SendRoleChangeNotification(telegramID int64, serverName string, newRole string) {\n\tmsg := fmt.Sprintf(\"ℹ️ Изменение прав доступа\\n\\nСервер: %s\\nВаша новая роль: %s\", serverName, newRole)\n\tbot.b.Send(&tele.User{ID: telegramID}, msg, tele.ModeHTML)\n}\n\nfunc (bot *Bot) SendRemovalNotification(telegramID int64, serverName string) {\n\tmsg := fmt.Sprintf(\"⛔ Доступ закрыт\\n\\nВы были отключены от сервера %s.\", serverName)\n\tbot.b.Send(&tele.User{ID: telegramID}, msg, tele.ModeHTML)\n}\n\nfunc (bot *Bot) handleAdminCommand(c tele.Context) error {\n\tuserID := c.Sender().ID\n\tif _, isAdmin := bot.adminIDs[userID]; !isAdmin {\n\t\treturn nil\n\t}\n\tmenu := &tele.ReplyMarkup{}\n\tbtnServers := menu.Data(\"🏢 Список серверов\", \"adm_list_servers\")\n\tmenu.Inline(menu.Row(btnServers))\n\treturn c.Send(\"🕵️‍♂️ Super Admin Panel\\n\\nВыберите действие:\", menu, tele.ModeHTML)\n}\n\nfunc (bot *Bot) adminListServers(c tele.Context) error {\n\tservers, err := bot.accountRepo.GetAllServersSystemWide()\n\tif err != nil {\n\t\treturn c.Send(\"Error: \" + err.Error())\n\t}\n\tmenu := &tele.ReplyMarkup{}\n\tvar rows []tele.Row\n\tfor _, s := range servers {\n\t\tbtn := menu.Data(fmt.Sprintf(\"🖥 %s\", s.Name), \"adm_srv_\"+s.ID.String())\n\t\trows = append(rows, menu.Row(btn))\n\t}\n\tmenu.Inline(rows...)\n\treturn c.EditOrSend(\"Все серверы системы:\", menu, tele.ModeHTML)\n}\n\nfunc (bot *Bot) renderMainMenu(c tele.Context) error {\n\tbot.fsm.Reset(c.Sender().ID)\n\tuserDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)\n\tactiveServer, _ := bot.accountRepo.GetActiveServer(userDB.ID)\n\n\tmenu := &tele.ReplyMarkup{}\n\tbtnServers := menu.Data(\"🖥 Серверы\", \"nav_servers\")\n\tbtnDicts := menu.Data(\"🔄 Справочники\", \"nav_dicts\")\n\tbtnBalance := menu.Data(\"💰 Баланс\", \"nav_balance\")\n\n\tvar rows []tele.Row\n\trows = append(rows, menu.Row(btnServers, btnDicts))\n\trows = append(rows, menu.Row(btnBalance))\n\n\tshowApp := false\n\tif activeServer != nil {\n\t\trole, _ := bot.accountRepo.GetUserRole(userDB.ID, activeServer.ID)\n\t\tif role == account.RoleOwner || role == account.RoleAdmin {\n\t\t\tshowApp = true\n\t\t}\n\t}\n\n\tif showApp {\n\t\tbtnApp := menu.WebApp(\"📱 Открыть приложение\", &tele.WebApp{URL: bot.webAppURL})\n\t\trows = append(rows, menu.Row(btnApp))\n\t}\n\n\tmenu.Inline(rows...)\n\ttxt := \"👋 Панель управления RMSER\\n\\n\" +\n\t\t\"Здесь вы можете управлять подключенными серверами iiko и следить за актуальностью справочников.\"\n\n\tif activeServer != nil {\n\t\trole, _ := bot.accountRepo.GetUserRole(userDB.ID, activeServer.ID)\n\t\ttxt += fmt.Sprintf(\"\\n\\nАктивный сервер: %s (%s)\", activeServer.Name, role)\n\t}\n\n\treturn c.EditOrSend(txt, menu, tele.ModeHTML)\n}\n\nfunc (bot *Bot) renderServersMenu(c tele.Context) error {\n\tuserDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)\n\tservers, err := bot.accountRepo.GetAllAvailableServers(userDB.ID)\n\tif err != nil {\n\t\treturn c.Send(\"Ошибка БД: \" + err.Error())\n\t}\n\tactiveServer, _ := bot.accountRepo.GetActiveServer(userDB.ID)\n\n\tmenu := &tele.ReplyMarkup{}\n\tvar rows []tele.Row\n\tfor _, s := range servers {\n\t\ticon := \"🔴\"\n\t\tif activeServer != nil && activeServer.ID == s.ID {\n\t\t\ticon = \"🟢\"\n\t\t}\n\t\trole, _ := bot.accountRepo.GetUserRole(userDB.ID, s.ID)\n\t\tlabel := fmt.Sprintf(\"%s %s (%s)\", icon, s.Name, role)\n\t\tbtn := menu.Data(label, \"set_server_\"+s.ID.String())\n\t\trows = append(rows, menu.Row(btn))\n\t}\n\n\tbtnAdd := menu.Data(\"➕ Добавить сервер\", \"act_add_server\")\n\tbtnDel := menu.Data(\"⚙️ Управление / Удаление\", \"act_del_server_menu\")\n\tbtnBack := menu.Data(\"🔙 Назад\", \"nav_main\")\n\n\trows = append(rows, menu.Row(btnAdd, btnDel))\n\trows = append(rows, menu.Row(btnBack))\n\tmenu.Inline(rows...)\n\n\ttxt := fmt.Sprintf(\"🖥 Ваши серверы (%d):\\n\\nНажмите на сервер, чтобы сделать его активным.\", len(servers))\n\treturn c.EditOrSend(txt, menu, tele.ModeHTML)\n}\n\nfunc (bot *Bot) renderDictsMenu(c tele.Context) error {\n\tuserDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)\n\tstats, err := bot.syncService.GetSyncStats(userDB.ID)\n\tvar txt string\n\tif err != nil {\n\t\ttxt = fmt.Sprintf(\"⚠️ Статус: Ошибка или нет активного сервера (%v)\", err)\n\t} else {\n\t\tlastUpdate := \"—\"\n\t\tif stats.LastInvoice != nil {\n\t\t\tlastUpdate = stats.LastInvoice.Format(\"02.01.2006\")\n\t\t}\n\t\ttxt = fmt.Sprintf(\"🔄 Состояние справочников\\n\\n\"+\n\t\t\t\"🏢 Сервер: %s\\n\"+\n\t\t\t\"📦 Товары: %d\\n\"+\n\t\t\t\"🚚 Поставщики: %d\\n\"+\n\t\t\t\"🏭 Склады: %d\\n\\n\"+\n\t\t\t\"📄 Накладные (30дн): %d\\n\"+\n\t\t\t\"📅 Посл. документ: %s\\n\\n\"+\n\t\t\t\"Нажмите «Обновить», чтобы синхронизировать данные.\",\n\t\t\tstats.ServerName, stats.ProductsCount, stats.SuppliersCount, stats.StoresCount, stats.InvoicesLast30, lastUpdate)\n\t}\n\treturn c.EditOrSend(txt, bot.menuDicts, tele.ModeHTML)\n}\n\nfunc (bot *Bot) renderBalanceMenu(c tele.Context) error {\n\tuserDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)\n\tactiveServer, _ := bot.accountRepo.GetActiveServer(userDB.ID)\n\n\ttxt := \"💰 Баланс и Тарифы\\n\\n\"\n\tif activeServer == nil {\n\t\ttxt += \"❌ У вас нет активного сервера. Сначала подключите сервер в меню «Серверы».\"\n\t} else {\n\t\tpaidUntil := \"не активно\"\n\t\tif activeServer.PaidUntil != nil {\n\t\t\tpaidUntil = activeServer.PaidUntil.Format(\"02.01.2006\")\n\t\t}\n\t\ttxt += fmt.Sprintf(\"🏢 Сервер: %s\\n\", activeServer.Name)\n\t\ttxt += fmt.Sprintf(\"📄 Остаток накладных: %d шт.\\n\", activeServer.Balance)\n\t\ttxt += fmt.Sprintf(\"📅 Доступен до: %s\\n\\n\", paidUntil)\n\t\ttxt += \"Выберите способ пополнения:\"\n\t}\n\n\tmenu := &tele.ReplyMarkup{}\n\tvar rows []tele.Row\n\tif activeServer != nil {\n\t\tbtnTopUp := menu.Data(\"💳 Пополнить баланс\", \"bill_topup\")\n\t\tbtnGift := menu.Data(\"🎁 Подарок другу\", \"bill_gift\")\n\t\trows = append(rows, menu.Row(btnTopUp, btnGift))\n\t}\n\tbtnBack := menu.Data(\"🔙 Назад\", \"nav_main\")\n\trows = append(rows, menu.Row(btnBack))\n\tmenu.Inline(rows...)\n\n\treturn c.EditOrSend(txt, menu, tele.ModeHTML)\n}\n\nfunc (bot *Bot) renderTariffShowcase(c tele.Context, targetURL string) error {\n\ttariffs := bot.billingService.GetTariffs()\n\tmenu := &tele.ReplyMarkup{}\n\ttxt := \"🛒 Выберите тарифный план\\n\"\n\tif targetURL != \"\" {\n\t\ttxt += fmt.Sprintf(\"🎁 Оформление подарка для сервера: %s\\n\", targetURL)\n\t}\n\ttxt += \"\\nПакеты (разово):\"\n\n\tvar rows []tele.Row\n\tfor _, t := range tariffs {\n\t\tlabel := fmt.Sprintf(\"%s — %.0f₽ (%d шт)\", t.Name, t.Price, t.InvoicesCount)\n\t\tbtn := menu.Data(label, \"buy_id_\"+t.ID)\n\t\trows = append(rows, menu.Row(btn))\n\t}\n\tbtnBack := menu.Data(\"🔙 Отмена\", \"nav_balance\")\n\trows = append(rows, menu.Row(btnBack))\n\tmenu.Inline(rows...)\n\treturn c.EditOrSend(txt, menu, tele.ModeHTML)\n}\n\nfunc (bot *Bot) handleCallback(c tele.Context) error {\n\tdata := c.Callback().Data\n\tif len(data) > 0 && data[0] == '\\f' {\n\t\tdata = data[1:]\n\t}\n\n\tuserDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)\n\tuserID := c.Sender().ID\n\tif bot.maintenanceMode && !bot.isDev(userID) {\n\t\treturn c.Respond(&tele.CallbackResponse{Text: \"Сервис на обслуживании\"})\n\t}\n\n\t// --- INTEGRATION: Billing Callbacks ---\n\tif strings.HasPrefix(data, \"bill_\") || strings.HasPrefix(data, \"buy_id_\") || strings.HasPrefix(data, \"pay_\") {\n\t\treturn bot.handleBillingCallbacks(c, data, userDB)\n\t}\n\n\tif strings.HasPrefix(data, \"set_server_\") {\n\t\tserverIDStr := strings.TrimPrefix(data, \"set_server_\")\n\t\tserverIDStr = strings.TrimSpace(serverIDStr)\n\t\tif idx := strings.Index(serverIDStr, \"|\"); idx != -1 {\n\t\t\tserverIDStr = serverIDStr[:idx]\n\t\t}\n\t\ttargetID := parseUUID(serverIDStr)\n\t\tif err := bot.accountRepo.SetActiveServer(userDB.ID, targetID); err != nil {\n\t\t\tlogger.Log.Error(\"Failed to set active server\", zap.Error(err))\n\t\t\treturn c.Respond(&tele.CallbackResponse{Text: \"Ошибка: доступ запрещен\"})\n\t\t}\n\t\tbot.rmsFactory.ClearCacheForUser(userDB.ID)\n\t\tc.Respond(&tele.CallbackResponse{Text: \"✅ Сервер выбран\"})\n\t\treturn bot.renderServersMenu(c)\n\t}\n\n\tif strings.HasPrefix(data, \"do_del_server_\") {\n\t\tserverIDStr := strings.TrimPrefix(data, \"do_del_server_\")\n\t\tserverIDStr = strings.TrimSpace(serverIDStr)\n\t\tif idx := strings.Index(serverIDStr, \"|\"); idx != -1 {\n\t\t\tserverIDStr = serverIDStr[:idx]\n\t\t}\n\t\ttargetID := parseUUID(serverIDStr)\n\t\trole, err := bot.accountRepo.GetUserRole(userDB.ID, targetID)\n\t\tif err != nil {\n\t\t\treturn c.Respond(&tele.CallbackResponse{Text: \"Ошибка прав доступа\"})\n\t\t}\n\t\tif role == account.RoleOwner {\n\t\t\tif err := bot.accountRepo.DeleteServer(targetID); err != nil {\n\t\t\t\treturn c.Respond(&tele.CallbackResponse{Text: \"Ошибка удаления\"})\n\t\t\t}\n\t\t\tbot.rmsFactory.ClearCacheForUser(userDB.ID)\n\t\t\tc.Respond(&tele.CallbackResponse{Text: \"Сервер полностью удален\"})\n\t\t} else {\n\t\t\tif err := bot.accountRepo.RemoveUserFromServer(targetID, userDB.ID); err != nil {\n\t\t\t\treturn c.Respond(&tele.CallbackResponse{Text: \"Ошибка выхода\"})\n\t\t\t}\n\t\t\tbot.rmsFactory.ClearCacheForUser(userDB.ID)\n\t\t\tc.Respond(&tele.CallbackResponse{Text: \"Вы покинули сервер\"})\n\t\t}\n\t\tactive, _ := bot.accountRepo.GetActiveServer(userDB.ID)\n\t\tif active == nil {\n\t\t\tall, _ := bot.accountRepo.GetAllAvailableServers(userDB.ID)\n\t\t\tif len(all) > 0 {\n\t\t\t\t_ = bot.accountRepo.SetActiveServer(userDB.ID, all[0].ID)\n\t\t\t}\n\t\t}\n\t\treturn bot.renderDeleteServerMenu(c)\n\t}\n\n\tif strings.HasPrefix(data, \"gen_invite_\") {\n\t\tserverIDStr := strings.TrimPrefix(data, \"gen_invite_\")\n\t\tlink := fmt.Sprintf(\"https://t.me/%s?start=invite_%s\", bot.b.Me.Username, serverIDStr)\n\t\tc.Respond()\n\t\treturn c.Send(fmt.Sprintf(\"🔗 Ссылка для приглашения:\\n\\n%s\\n\\nОтправьте её сотруднику.\", link), tele.ModeHTML)\n\t}\n\n\tif strings.HasPrefix(data, \"adm_srv_\") {\n\t\tserverIDStr := strings.TrimPrefix(data, \"adm_srv_\")\n\t\tserverID := parseUUID(serverIDStr)\n\t\treturn bot.renderServerUsers(c, serverID)\n\t}\n\n\tif strings.HasPrefix(data, \"adm_usr_\") {\n\t\tconnIDStr := strings.TrimPrefix(data, \"adm_usr_\")\n\t\tconnID := parseUUID(connIDStr)\n\t\tlink, err := bot.accountRepo.GetConnectionByID(connID)\n\t\tif err != nil {\n\t\t\treturn c.Respond(&tele.CallbackResponse{Text: \"Ошибка: связь не найдена\"})\n\t\t}\n\t\tif link.Role == account.RoleOwner {\n\t\t\treturn c.Respond(&tele.CallbackResponse{Text: \"Этот пользователь уже Владелец\"})\n\t\t}\n\t\tmenu := &tele.ReplyMarkup{}\n\t\tbtnYes := menu.Data(\"✅ Сделать Владельцем\", fmt.Sprintf(\"adm_own_yes_%s\", link.ID.String()))\n\t\tbtnNo := menu.Data(\"Отмена\", \"adm_srv_\"+link.ServerID.String())\n\t\tmenu.Inline(menu.Row(btnYes), menu.Row(btnNo))\n\t\ttxt := fmt.Sprintf(\"⚠️ Внимание!\\n\\nВы собираетесь передать права Владельца сервера %s пользователю %s.\\n\\nТекущий владелец станет Администратором.\",\n\t\t\tlink.Server.Name, link.User.FirstName)\n\t\treturn c.EditOrSend(txt, menu, tele.ModeHTML)\n\t}\n\n\tif strings.HasPrefix(data, \"adm_own_yes_\") {\n\t\tconnIDStr := strings.TrimPrefix(data, \"adm_own_yes_\")\n\t\tconnID := parseUUID(connIDStr)\n\t\tlink, err := bot.accountRepo.GetConnectionByID(connID)\n\t\tif err != nil {\n\t\t\treturn c.Respond(&tele.CallbackResponse{Text: \"Ошибка: связь не найдена\"})\n\t\t}\n\t\tif err := bot.accountRepo.TransferOwnership(link.ServerID, link.UserID); err != nil {\n\t\t\tlogger.Log.Error(\"Ownership transfer failed\", zap.Error(err))\n\t\t\treturn c.Respond(&tele.CallbackResponse{Text: \"Ошибка: \" + err.Error()})\n\t\t}\n\t\tgo func() {\n\t\t\tmsg := fmt.Sprintf(\"👑 Поздравляем!\\n\\nВам переданы права Владельца (OWNER) сервера %s.\", link.Server.Name)\n\t\t\tbot.b.Send(&tele.User{ID: link.User.TelegramID}, msg, tele.ModeHTML)\n\t\t}()\n\t\tc.Respond(&tele.CallbackResponse{Text: \"Успешно!\"})\n\t\treturn bot.renderServerUsers(c, link.ServerID)\n\t}\n\n\treturn nil\n}\n\n// Реализация метода интерфейса PaymentNotifier\nfunc (bot *Bot) NotifySuccess(userID uuid.UUID, amount float64, newBalance int, serverName string) {\n\n\tuser, err := bot.accountRepo.GetUserByID(userID)\n\tif err != nil {\n\t\tlogger.Log.Error(\"Failed to find user for payment notification\", zap.Error(err))\n\t\treturn\n\t}\n\n\tmsg := fmt.Sprintf(\n\t\t\"✅ Оплата получена!\\n\\n\"+\n\t\t\t\"Сумма: %.2f ₽\\n\"+\n\t\t\t\"Сервер: %s\\n\"+\n\t\t\t\"Текущий баланс: %d накладных\\n\\n\"+\n\t\t\t\"Спасибо за использование RMSer!\",\n\t\tamount, serverName, newBalance,\n\t)\n\n\tbot.b.Send(&tele.User{ID: user.TelegramID}, msg, tele.ModeHTML)\n}\n\nfunc (bot *Bot) triggerFullSync(c tele.Context) error {\n\tuserDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)\n\tserver, _ := bot.accountRepo.GetActiveServer(userDB.ID)\n\tif server == nil {\n\t\treturn c.Respond(&tele.CallbackResponse{Text: \"Нет активного сервера\"})\n\t}\n\n\tc.Respond(&tele.CallbackResponse{Text: \"Запущена полная перезагрузка данных...\", ShowAlert: false})\n\tc.Send(\"⏳ Полная синхронизация\\\\nОбновляю историю накладных за 60 дней и справочники. Это может занять до 1 минуты.\", tele.ModeHTML)\n\n\tgo func() {\n\t\tif err := bot.syncService.SyncAllData(userDB.ID, true); err != nil {\n\t\t\tbot.b.Send(c.Sender(), \"❌ Ошибка при полной синхронизации: \"+err.Error())\n\t\t} else {\n\t\t\tbot.b.Send(c.Sender(), \"✅ Все данные успешно обновлены!\")\n\t\t}\n\t}()\n\treturn nil\n}\n\nfunc (bot *Bot) handleBillingCallbacks(c tele.Context, data string, userDB *account.User) error {\n\tif data == \"bill_topup\" {\n\t\treturn bot.renderTariffShowcase(c, \"\")\n\t}\n\n\tif data == \"bill_gift\" {\n\t\tbot.fsm.SetState(c.Sender().ID, StateBillingGiftURL)\n\t\treturn c.EditOrSend(\"🎁 Режим подарка\\n\\nВведите URL сервера, который хотите пополнить (например: https://myresto.iiko.it).\\n\\nСервер должен быть уже зарегистрирован в нашей системе.\", tele.ModeHTML)\n\t}\n\n\tif strings.HasPrefix(data, \"pay_confirm_\") {\n\t\torderIDStr := strings.TrimPrefix(data, \"pay_confirm_\")\n\t\torderID, _ := uuid.Parse(orderIDStr)\n\t\tif err := bot.billingService.ConfirmOrder(orderID); err != nil {\n\t\t\treturn c.Respond(&tele.CallbackResponse{Text: \"Ошибка: \" + err.Error()})\n\t\t}\n\t\tc.Respond(&tele.CallbackResponse{Text: \"✨ Оплата успешно имитирована!\"})\n\t\tbot.fsm.Reset(c.Sender().ID)\n\t\treturn c.EditOrSend(\"✅ Оплата прошла успешно!\\n\\nУслуги начислены на баланс сервера. Теперь вы можете продолжить работу с накладными.\", bot.menuBalance, tele.ModeHTML)\n\t}\n\n\tif strings.HasPrefix(data, \"buy_id_\") {\n\t\ttariffID := strings.TrimPrefix(data, \"buy_id_\")\n\t\tctxFSM := bot.fsm.GetContext(c.Sender().ID)\n\n\t\t// 1. Формируем URL возврата (ссылка на бота)\n\t\treturnURL := fmt.Sprintf(\"https://t.me/%s\", bot.b.Me.Username)\n\n\t\t// 2. Вызываем обновленный метод (теперь возвращает 3 значения)\n\t\torder, payURL, err := bot.billingService.CreateOrder(\n\t\t\tcontext.Background(),\n\t\t\tuserDB.ID,\n\t\t\ttariffID,\n\t\t\tctxFSM.BillingTargetURL,\n\t\t\treturnURL,\n\t\t)\n\t\tif err != nil {\n\t\t\treturn c.Send(\"❌ Ошибка при формировании счета: \" + err.Error())\n\t\t}\n\n\t\t// 3. Создаем кнопку с URL на ЮКассу\n\t\tmenu := &tele.ReplyMarkup{}\n\t\tbtnPay := menu.URL(\"💳 Оплатить\", payURL) // payURL теперь string\n\t\tbtnBack := menu.Data(\"🔙 Отмена\", \"nav_balance\")\n\t\tmenu.Inline(menu.Row(btnPay), menu.Row(btnBack))\n\n\t\ttxt := fmt.Sprintf(\n\t\t\t\"📦 Заказ №%s\\n\\nСумма к оплате: %.2f ₽\\n\\nНажмите кнопку ниже для перехода к оплате. Баланс будет пополнен автоматически сразу после подтверждения платежа.\",\n\t\t\torder.ID.String()[:8], // Показываем короткий ID для красоты\n\t\t\torder.Amount,\n\t\t)\n\t\treturn c.EditOrSend(txt, menu, tele.ModeHTML)\n\t}\n\n\treturn nil\n}\n\nfunc (bot *Bot) renderServerUsers(c tele.Context, serverID uuid.UUID) error {\n\tusers, err := bot.accountRepo.GetServerUsers(serverID)\n\tif err != nil {\n\t\treturn c.Respond(&tele.CallbackResponse{Text: \"Ошибка загрузки юзеров\"})\n\t}\n\tmenu := &tele.ReplyMarkup{}\n\tvar rows []tele.Row\n\tfor _, u := range users {\n\t\troleIcon := \"👤\"\n\t\tif u.Role == account.RoleOwner {\n\t\t\troleIcon = \"👑\"\n\t\t}\n\t\tif u.Role == account.RoleAdmin {\n\t\t\troleIcon = \"⭐️\"\n\t\t}\n\t\tlabel := fmt.Sprintf(\"%s %s %s\", roleIcon, u.User.FirstName, u.User.LastName)\n\t\tpayload := fmt.Sprintf(\"adm_usr_%s\", u.ID.String())\n\t\tbtn := menu.Data(label, payload)\n\t\trows = append(rows, menu.Row(btn))\n\t}\n\tbtnBack := menu.Data(\"🔙 К серверам\", \"adm_list_servers\")\n\trows = append(rows, menu.Row(btnBack))\n\tmenu.Inline(rows...)\n\tserverName := \"Unknown\"\n\tif len(users) > 0 {\n\t\tserverName = users[0].Server.Name\n\t}\n\treturn c.EditOrSend(fmt.Sprintf(\"👥 Пользователи сервера %s:\", serverName), menu, tele.ModeHTML)\n}\n\nfunc (bot *Bot) triggerSync(c tele.Context) error {\n\tuserDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)\n\tserver, err := bot.accountRepo.GetActiveServer(userDB.ID)\n\tif err != nil || server == nil {\n\t\treturn c.Respond(&tele.CallbackResponse{Text: \"Нет активного сервера\"})\n\t}\n\trole, _ := bot.accountRepo.GetUserRole(userDB.ID, server.ID)\n\tif role == account.RoleOperator {\n\t\treturn c.Respond(&tele.CallbackResponse{Text: \"⚠️ Синхронизация доступна только Админам\", ShowAlert: true})\n\t}\n\tc.Respond(&tele.CallbackResponse{Text: \"Запускаю синхронизацию...\"})\n\tgo func() {\n\t\tif err := bot.syncService.SyncAllData(userDB.ID, false); err != nil {\n\t\t\tlogger.Log.Error(\"Manual sync failed\", zap.Error(err))\n\t\t\tbot.b.Send(c.Sender(), \"❌ Ошибка синхронизации. Проверьте настройки сервера.\")\n\t\t} else {\n\t\t\tbot.b.Send(c.Sender(), \"✅ Синхронизация успешно завершена!\")\n\t\t}\n\t}()\n\treturn nil\n}\n\nfunc (bot *Bot) startAddServerFlow(c tele.Context) error {\n\tbot.fsm.SetState(c.Sender().ID, StateAddServerURL)\n\treturn c.EditOrSend(\"🔗 Введите URL вашего сервера iikoRMS.\\nПример: https://resto.iiko.it\\n\\n(Напишите 'отмена' для выхода)\", tele.ModeHTML)\n}\n\nfunc (bot *Bot) handleText(c tele.Context) error {\n\tuserID := c.Sender().ID\n\tstate := bot.fsm.GetState(userID)\n\ttext := strings.TrimSpace(c.Text())\n\n\tif bot.maintenanceMode && !bot.isDev(userID) {\n\t\treturn c.Send(\"Сервис на обслуживании\", tele.ModeHTML)\n\t}\n\n\tif strings.ToLower(text) == \"отмена\" || strings.ToLower(text) == \"/cancel\" {\n\t\tbot.fsm.Reset(userID)\n\t\treturn bot.renderMainMenu(c)\n\t}\n\n\tif state == StateNone {\n\t\treturn c.Send(\"Используйте меню для навигации 👇\")\n\t}\n\n\tswitch state {\n\tcase StateAddServerURL:\n\t\tif !strings.HasPrefix(text, \"http\") {\n\t\t\treturn c.Send(\"❌ URL должен начинаться с http:// или https://\\nПопробуйте снова.\")\n\t\t}\n\t\tbot.fsm.UpdateContext(userID, func(ctx *UserContext) {\n\t\t\tctx.TempURL = strings.TrimRight(text, \"/\")\n\t\t\tctx.State = StateAddServerLogin\n\t\t})\n\t\treturn c.Send(\"👤 Введите логин пользователя iiko:\")\n\n\tcase StateAddServerLogin:\n\t\tbot.fsm.UpdateContext(userID, func(ctx *UserContext) {\n\t\t\tctx.TempLogin = text\n\t\t\tctx.State = StateAddServerPassword\n\t\t})\n\t\treturn c.Send(\"🔑 Введите пароль:\")\n\n\tcase StateAddServerPassword:\n\t\tpassword := text\n\t\tctx := bot.fsm.GetContext(userID)\n\t\tmsg, _ := bot.b.Send(c.Sender(), \"⏳ Проверяю подключение...\")\n\t\ttempClient := bot.rmsFactory.CreateClientFromRawCredentials(ctx.TempURL, ctx.TempLogin, password)\n\t\tif err := tempClient.Auth(); err != nil {\n\t\t\tbot.b.Delete(msg)\n\t\t\treturn c.Send(fmt.Sprintf(\"❌ Ошибка авторизации: %v\\nПроверьте логин/пароль.\", err))\n\t\t}\n\t\tvar detectedName string\n\t\tinfo, err := rms.GetServerInfo(ctx.TempURL)\n\t\tif err == nil && info.ServerName != \"\" {\n\t\t\tdetectedName = info.ServerName\n\t\t}\n\t\tbot.b.Delete(msg)\n\t\tbot.fsm.UpdateContext(userID, func(uCtx *UserContext) {\n\t\t\tuCtx.TempPassword = password\n\t\t\tuCtx.TempServerName = detectedName\n\t\t})\n\t\tif detectedName != \"\" {\n\t\t\tbot.fsm.SetState(userID, StateAddServerConfirmName)\n\t\t\tmenu := &tele.ReplyMarkup{}\n\t\t\tbtnYes := menu.Data(\"✅ Да, использовать это имя\", \"confirm_name_yes\")\n\t\t\tbtnNo := menu.Data(\"✏️ Ввести другое\", \"confirm_name_no\")\n\t\t\tmenu.Inline(menu.Row(btnYes), menu.Row(btnNo))\n\t\t\treturn c.Send(fmt.Sprintf(\"🔎 Обнаружено имя сервера: %s.\\nИспользовать его?\", detectedName), menu, tele.ModeHTML)\n\t\t}\n\t\tbot.fsm.SetState(userID, StateAddServerInputName)\n\t\treturn c.Send(\"🏷 Введите название для этого сервера:\")\n\n\tcase StateAddServerInputName:\n\t\tname := text\n\t\tif len(name) < 3 {\n\t\t\treturn c.Send(\"⚠️ Название слишком короткое.\")\n\t\t}\n\t\treturn bot.saveServerFinal(c, userID, name)\n\n\tcase StateBillingGiftURL:\n\t\tif !strings.HasPrefix(text, \"http\") {\n\t\t\treturn c.Send(\"❌ Некорректный URL. Он должен начинаться с http:// или https://\")\n\t\t}\n\t\t_, err := bot.accountRepo.GetServerByURL(text)\n\t\tif err != nil {\n\t\t\treturn c.Send(\"🔍 Сервер с таким URL не найден в системе. Попросите владельца сначала подключить его к боту.\")\n\t\t}\n\t\tbot.fsm.UpdateContext(c.Sender().ID, func(ctx *UserContext) {\n\t\t\tctx.BillingTargetURL = text\n\t\t})\n\t\treturn bot.renderTariffShowcase(c, text)\n\t}\n\n\treturn nil\n}\n\nfunc (bot *Bot) handlePhoto(c tele.Context) error {\n\tuserDB, err := bot.accountRepo.GetOrCreateUser(c.Sender().ID, c.Sender().Username, \"\", \"\")\n\tif err != nil {\n\t\treturn c.Send(\"Ошибка базы данных пользователей\")\n\t}\n\tuserID := c.Sender().ID\n\t_, err = bot.rmsFactory.GetClientForUser(userDB.ID)\n\tif err != nil {\n\t\treturn c.Send(\"⛔ Ошибка доступа к iiko или сервер не выбран.\\nПроверьте статус сервера в меню.\")\n\t}\n\tphoto := c.Message().Photo\n\tfile, err := bot.b.FileByID(photo.FileID)\n\tif err != nil {\n\t\treturn c.Send(\"Ошибка доступа к файлу.\")\n\t}\n\tfileURL := fmt.Sprintf(\"https://api.telegram.org/file/bot%s/%s\", bot.b.Token, file.FilePath)\n\tresp, err := http.Get(fileURL)\n\tif err != nil {\n\t\treturn c.Send(\"Ошибка скачивания файла.\")\n\t}\n\tdefer resp.Body.Close()\n\timgData, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn c.Send(\"Ошибка чтения файла.\")\n\t}\n\tc.Send(\"⏳ ИИ анализирует документ...\\nОбрабатываю позиции и ищу совпадения в вашей базе iiko.\", tele.ModeHTML)\n\tctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)\n\tdefer cancel()\n\tdraft, err := bot.ocrService.ProcessReceiptImage(ctx, userDB.ID, imgData)\n\tif err != nil {\n\t\tlogger.Log.Error(\"OCR processing failed\", zap.Error(err))\n\t\treturn c.Send(\"❌ Ошибка обработки: \" + err.Error())\n\t}\n\tmatchedCount := 0\n\tfor _, item := range draft.Items {\n\t\tif item.IsMatched {\n\t\t\tmatchedCount++\n\t\t}\n\t}\n\tif bot.isDev(userID) {\n\t\tbaseURL := strings.TrimRight(bot.webAppURL, \"/\")\n\t\tfullURL := fmt.Sprintf(\"%s/invoice/%s\", baseURL, draft.ID.String())\n\t\tvar msgText string\n\t\tif matchedCount == len(draft.Items) {\n\t\t\tmsgText = fmt.Sprintf(\"✅ Все позиции распознаны!\\n\\nВсе товары найдены в вашей номенклатуре. Проверьте количество (%d) и цену в приложении перед отправкой.\", len(draft.Items))\n\t\t} else {\n\t\t\tmsgText = fmt.Sprintf(\"⚠️ Распознано позиций: %d из %d\\n\\nОстальные товары я вижу впервые. Воспользуйтесь удобным интерфейсом сопоставления в приложении — я запомню ваш выбор навсегда.\", matchedCount, len(draft.Items))\n\t\t}\n\t\tmenu := &tele.ReplyMarkup{}\n\t\trole, _ := bot.accountRepo.GetUserRole(userDB.ID, draft.RMSServerID)\n\t\tif role != account.RoleOperator {\n\t\t\tbtnOpen := menu.WebApp(\"📝 Открыть и сопоставить\", &tele.WebApp{URL: fullURL})\n\t\t\tmenu.Inline(menu.Row(btnOpen))\n\t\t} else {\n\t\t\tmsgText += \"\\n\\n(Редактирование доступно Администратору)\"\n\t\t}\n\t\treturn c.Send(msgText, menu, tele.ModeHTML)\n\t} else {\n\t\treturn c.Send(\"✅ **Фото принято!** Мы получили ваш документ и передали его оператору. Спасибо!\", tele.ModeHTML)\n\t}\n}\n\nfunc (bot *Bot) handleConfirmNameYes(c tele.Context) error {\n\tuserID := c.Sender().ID\n\tctx := bot.fsm.GetContext(userID)\n\tif ctx.State != StateAddServerConfirmName {\n\t\treturn c.Respond()\n\t}\n\treturn bot.saveServerFinal(c, userID, ctx.TempServerName)\n}\n\nfunc (bot *Bot) handleConfirmNameNo(c tele.Context) error {\n\tuserID := c.Sender().ID\n\tbot.fsm.SetState(userID, StateAddServerInputName)\n\treturn c.EditOrSend(\"🏷 Хорошо, введите желаемое название:\")\n}\n\n// Обновленный метод saveServerFinal (добавление уведомления о бонусе)\nfunc (bot *Bot) saveServerFinal(c tele.Context, userID int64, serverName string) error {\n\tctx := bot.fsm.GetContext(userID)\n\tuserDB, _ := bot.accountRepo.GetOrCreateUser(userID, c.Sender().Username, \"\", \"\")\n\tencPass, _ := bot.cryptoManager.Encrypt(ctx.TempPassword)\n\n\tserver, err := bot.accountRepo.ConnectServer(userDB.ID, ctx.TempURL, ctx.TempLogin, encPass, serverName)\n\tif err != nil {\n\t\treturn c.Send(\"❌ Ошибка подключения сервера: \" + err.Error())\n\t}\n\tbot.fsm.Reset(userID)\n\n\trole, _ := bot.accountRepo.GetUserRole(userDB.ID, server.ID)\n\n\tsuccessMsg := fmt.Sprintf(\"✅ Сервер %s успешно подключен!\\nВаша роль: %s\\n\\n\", server.Name, role)\n\n\t// Проверяем, новый ли это сервер по балансу и дате создания (упрощенно для уведомления)\n\tif server.Balance == 10 {\n\t\tsuccessMsg += \"🎁 Вам начислен приветственный бонус: 10 накладных на 30 дней! Пользуйтесь с удовольствием.\\n\\n\"\n\t}\n\n\tsuccessMsg += \"Начинаю первичную синхронизацию данных...\"\n\n\tc.Send(successMsg, tele.ModeHTML)\n\tgo bot.syncService.SyncAllData(userDB.ID, false)\n\n\treturn bot.renderMainMenu(c)\n}\n\nfunc (bot *Bot) renderDeleteServerMenu(c tele.Context) error {\n\tuserDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)\n\tservers, err := bot.accountRepo.GetAllAvailableServers(userDB.ID)\n\tif err != nil {\n\t\treturn c.Send(\"Ошибка БД: \" + err.Error())\n\t}\n\tif len(servers) == 0 {\n\t\treturn c.Respond(&tele.CallbackResponse{Text: \"Список серверов пуст\"})\n\t}\n\tmenu := &tele.ReplyMarkup{}\n\tvar rows []tele.Row\n\tfor _, s := range servers {\n\t\trole, _ := bot.accountRepo.GetUserRole(userDB.ID, s.ID)\n\t\tvar label string\n\t\tif role == account.RoleOwner {\n\t\t\tlabel = fmt.Sprintf(\"❌ Удалить %s (Owner)\", s.Name)\n\t\t} else {\n\t\t\tlabel = fmt.Sprintf(\"🚪 Покинуть %s\", s.Name)\n\t\t}\n\t\tbtnAction := menu.Data(label, \"do_del_server_\"+s.ID.String())\n\t\tif role == account.RoleOwner || role == account.RoleAdmin {\n\t\t\tbtnInvite := menu.Data(fmt.Sprintf(\"📩 Invite %s\", s.Name), \"gen_invite_\"+s.ID.String())\n\t\t\trows = append(rows, menu.Row(btnAction, btnInvite))\n\t\t} else {\n\t\t\trows = append(rows, menu.Row(btnAction))\n\t\t}\n\t}\n\tbtnBack := menu.Data(\"🔙 Назад к списку\", \"nav_servers\")\n\trows = append(rows, menu.Row(btnBack))\n\tmenu.Inline(rows...)\n\treturn c.EditOrSend(\"⚙️ Управление серверами\\n\\nЗдесь вы можете удалить сервер или пригласить сотрудников.\", menu, tele.ModeHTML)\n}\n\n// NotifyDevs отправляет фото разработчикам для отладки\nfunc (bot *Bot) NotifyDevs(devIDs []int64, photoPath string, serverName string, serverID string) {\n\t// Формируем подпись для фото\n\tcaption := fmt.Sprintf(\"🛠 **Debug Capture**\\nServer: %s (`%s`)\\nFile: %s\", serverName, serverID, photoPath)\n\n\t// В цикле отправляем фото каждому разработчику\n\tfor _, id := range devIDs {\n\t\tphoto := &tele.Photo{\n\t\t\tFile: tele.FromDisk(photoPath),\n\t\t\tCaption: caption,\n\t\t}\n\t\t// Отправляем фото пользователю\n\t\t_, err := bot.b.Send(&tele.User{ID: id}, photo)\n\t\tif err != nil {\n\t\t\tlogger.Log.Error(\"Failed to send debug photo\", zap.Int64(\"userID\", id), zap.Error(err))\n\t\t}\n\t}\n}\n\nfunc parseUUID(s string) uuid.UUID {\n\tid, _ := uuid.Parse(s)\n\treturn id\n}\n", + "internal/transport/telegram/fsm.go": "package telegram\n\nimport \"sync\"\n\n// Состояния пользователя\ntype State int\n\nconst (\n\tStateNone State = iota\n\tStateAddServerURL\n\tStateAddServerLogin\n\tStateAddServerPassword\n\tStateAddServerConfirmName\n\tStateAddServerInputName\n\tStateBillingGiftURL\n)\n\n// UserContext хранит временные данные в процессе диалога\ntype UserContext struct {\n\tState State\n\tTempURL string\n\tTempLogin string\n\tTempPassword string\n\tTempServerName string\n\tBillingTargetURL string\n}\n\n// StateManager управляет состояниями\ntype StateManager struct {\n\tmu sync.RWMutex\n\tstates map[int64]*UserContext\n}\n\nfunc NewStateManager() *StateManager {\n\treturn &StateManager{\n\t\tstates: make(map[int64]*UserContext),\n\t}\n}\n\nfunc (sm *StateManager) GetState(userID int64) State {\n\tsm.mu.RLock()\n\tdefer sm.mu.RUnlock()\n\tif ctx, ok := sm.states[userID]; ok {\n\t\treturn ctx.State\n\t}\n\treturn StateNone\n}\n\nfunc (sm *StateManager) SetState(userID int64, state State) {\n\tsm.mu.Lock()\n\tdefer sm.mu.Unlock()\n\n\tif _, ok := sm.states[userID]; !ok {\n\t\tsm.states[userID] = &UserContext{}\n\t}\n\tsm.states[userID].State = state\n}\n\nfunc (sm *StateManager) GetContext(userID int64) *UserContext {\n\tsm.mu.RLock()\n\tdefer sm.mu.RUnlock()\n\tif ctx, ok := sm.states[userID]; ok {\n\t\treturn ctx\n\t}\n\treturn &UserContext{} // Return empty safe struct\n}\n\nfunc (sm *StateManager) UpdateContext(userID int64, updater func(*UserContext)) {\n\tsm.mu.Lock()\n\tdefer sm.mu.Unlock()\n\n\tif _, ok := sm.states[userID]; !ok {\n\t\tsm.states[userID] = &UserContext{}\n\t}\n\tupdater(sm.states[userID])\n}\n\nfunc (sm *StateManager) Reset(userID int64) {\n\tsm.mu.Lock()\n\tdefer sm.mu.Unlock()\n\tdelete(sm.states, userID)\n}\n", + "pkg/crypto/crypto.go": "package crypto\n\nimport (\n\t\"crypto/aes\"\n\t\"crypto/cipher\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\" // <-- Добавлен импорт\n\t\"encoding/base64\"\n\t\"errors\"\n\t\"io\"\n)\n\n// CryptoManager занимается шифрованием чувствительных данных (паролей RMS)\ntype CryptoManager struct {\n\tkey []byte\n}\n\nfunc NewCryptoManager(secretKey string) *CryptoManager {\n\t// Исправление:\n\t// AES требует строго 16, 24 или 32 байта.\n\t// Чтобы не заставлять пользователя считать символы в конфиге,\n\t// мы хешируем любую строку в SHA-256, получая всегда валидные 32 байта.\n\thasher := sha256.New()\n\thasher.Write([]byte(secretKey))\n\tkeyBytes := hasher.Sum(nil)\n\n\treturn &CryptoManager{key: keyBytes}\n}\n\n// Encrypt шифрует строку и возвращает base64\nfunc (m *CryptoManager) Encrypt(plaintext string) (string, error) {\n\tblock, err := aes.NewCipher(m.key)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tgcm, err := cipher.NewGCM(block)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tnonce := make([]byte, gcm.NonceSize())\n\tif _, err := io.ReadFull(rand.Reader, nonce); err != nil {\n\t\treturn \"\", err\n\t}\n\n\tciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)\n\treturn base64.StdEncoding.EncodeToString(ciphertext), nil\n}\n\n// Decrypt расшифровывает base64 строку\nfunc (m *CryptoManager) Decrypt(ciphertextBase64 string) (string, error) {\n\tdata, err := base64.StdEncoding.DecodeString(ciphertextBase64)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tblock, err := aes.NewCipher(m.key)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tgcm, err := cipher.NewGCM(block)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tnonceSize := gcm.NonceSize()\n\tif len(data) < nonceSize {\n\t\treturn \"\", errors.New(\"ciphertext too short\")\n\t}\n\n\tnonce, ciphertext := data[:nonceSize], data[nonceSize:]\n\tplaintext, err := gcm.Open(nil, nonce, ciphertext, nil)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn string(plaintext), nil\n}\n", + "pkg/logger/logger.go": "package logger\n\nimport (\n\t\"go.uber.org/zap\"\n\t\"go.uber.org/zap/zapcore\"\n)\n\nvar Log *zap.Logger\n\nfunc Init(mode string) {\n\tvar config zap.Config\n\n\tif mode == \"release\" {\n\t\tconfig = zap.NewProductionConfig()\n\t} else {\n\t\tconfig = zap.NewDevelopmentConfig()\n\t\tconfig.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder\n\t}\n\n\tvar err error\n\tLog, err = config.Build()\n\tif err != nil {\n\t\tpanic(\"не удалось инициализировать логгер: \" + err.Error())\n\t}\n}", +} + +if __name__ == '__main__': + print('=== Дерево проекта ===') + print(project_tree) + print('\n=== Список файлов ===') + for name in project_files: + print(f'- {name}') diff --git a/internal/infrastructure/ocr_client/dto.go b/internal/infrastructure/ocr_client/dto.go index 3ad87cb..6eceed4 100644 --- a/internal/infrastructure/ocr_client/dto.go +++ b/internal/infrastructure/ocr_client/dto.go @@ -2,7 +2,9 @@ package ocr_client // RecognitionResult - ответ от Python сервиса type RecognitionResult struct { - Items []RecognizedItem `json:"items"` + Items []RecognizedItem `json:"items"` + DocNumber string `json:"doc_number"` + DocDate string `json:"doc_date"` } type RecognizedItem struct { diff --git a/internal/services/ocr/service.go b/internal/services/ocr/service.go index 6d350f3..26a8b60 100644 --- a/internal/services/ocr/service.go +++ b/internal/services/ocr/service.go @@ -78,8 +78,8 @@ func (s *Service) checkWriteAccess(userID, serverID uuid.UUID) error { return nil } -// ProcessReceiptImage - Доступно всем (включая Операторов) -func (s *Service) ProcessReceiptImage(ctx context.Context, userID uuid.UUID, imgData []byte) (*drafts.DraftInvoice, error) { +// ProcessDocument - Доступно всем (включая Операторов) +func (s *Service) ProcessDocument(ctx context.Context, userID uuid.UUID, imgData []byte, filename string) (*drafts.DraftInvoice, error) { server, err := s.accountRepo.GetActiveServer(userID) if err != nil || server == nil { return nil, fmt.Errorf("no active server for user") @@ -90,7 +90,7 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, userID uuid.UUID, img photoID := uuid.New() draftID := uuid.New() - fileName := fmt.Sprintf("receipt_%s.jpg", photoID.String()) + fileName := fmt.Sprintf("receipt_%s_%s", photoID.String(), filename) filePath := filepath.Join(s.storagePath, serverID.String(), fileName) // 2. Создаем директорию если не существует @@ -102,7 +102,7 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, userID uuid.UUID, img if err := os.WriteFile(filePath, imgData, 0644); err != nil { return nil, fmt.Errorf("failed to save image: %w", err) } - fileURL := "/uploads/" + fileName + fileURL := fmt.Sprintf("/uploads/%s/%s", serverID.String(), fileName) // 4. Создаем запись ReceiptPhoto photo := &photos.ReceiptPhoto{ @@ -111,7 +111,7 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, userID uuid.UUID, img UploadedBy: userID, FilePath: filePath, FileURL: fileURL, - FileName: fileName, + FileName: filename, FileSize: int64(len(imgData)), DraftID: &draftID, // Сразу связываем с будущим черновиком CreatedAt: time.Now(), @@ -140,14 +140,26 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, userID uuid.UUID, img } // 6. Отправляем в Python OCR - rawResult, err := s.pyClient.ProcessImage(ctx, imgData, "receipt.jpg") + rawResult, err := s.pyClient.ProcessImage(ctx, imgData, filename) if err != nil { draft.Status = drafts.StatusError _ = s.draftRepo.Update(draft) return nil, fmt.Errorf("python ocr error: %w", err) } - // 6. Матчинг и сохранение позиций + // Парсим дату документа + var dateIncoming *time.Time + if rawResult.DocDate != "" { + if t, err := time.Parse("02.01.2006", rawResult.DocDate); err == nil { + dateIncoming = &t + } + } + + // Заполняем номер и дату документа + draft.IncomingDocumentNumber = rawResult.DocNumber + draft.DateIncoming = dateIncoming + + // 7. Матчинг и сохранение позиций var draftItems []drafts.DraftInvoiceItem for _, rawItem := range rawResult.Items { item := drafts.DraftInvoiceItem{ diff --git a/internal/transport/telegram/bot.go b/internal/transport/telegram/bot.go index 4ff5f1e..2aebebc 100644 --- a/internal/transport/telegram/bot.go +++ b/internal/transport/telegram/bot.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "path/filepath" "strings" "time" @@ -34,9 +35,11 @@ type Bot struct { rmsFactory *rms.Factory cryptoManager *crypto.CryptoManager - fsm *StateManager - adminIDs map[int64]struct{} - webAppURL string + fsm *StateManager + adminIDs map[int64]struct{} + devIDs map[int64]struct{} + maintenanceMode bool + webAppURL string menuServers *tele.ReplyMarkup menuDicts *tele.ReplyMarkup @@ -51,6 +54,8 @@ func NewBot( accountRepo account.Repository, rmsFactory *rms.Factory, cryptoManager *crypto.CryptoManager, + maintenanceMode bool, + devIDs []int64, ) (*Bot, error) { pref := tele.Settings{ @@ -71,17 +76,24 @@ func NewBot( admins[id] = struct{}{} } + devs := make(map[int64]struct{}) + for _, id := range devIDs { + devs[id] = struct{}{} + } + bot := &Bot{ - b: b, - ocrService: ocrService, - syncService: syncService, - billingService: billingService, - accountRepo: accountRepo, - rmsFactory: rmsFactory, - cryptoManager: cryptoManager, - fsm: NewStateManager(), - adminIDs: admins, - webAppURL: cfg.WebAppURL, + b: b, + ocrService: ocrService, + syncService: syncService, + billingService: billingService, + accountRepo: accountRepo, + rmsFactory: rmsFactory, + cryptoManager: cryptoManager, + fsm: NewStateManager(), + adminIDs: admins, + devIDs: devs, + maintenanceMode: maintenanceMode, + webAppURL: cfg.WebAppURL, } if bot.webAppURL == "" { @@ -93,6 +105,11 @@ func NewBot( return bot, nil } +func (bot *Bot) isDev(userID int64) bool { + _, ok := bot.devIDs[userID] + return !bot.maintenanceMode || ok +} + func (bot *Bot) initMenus() { bot.menuServers = &tele.ReplyMarkup{} @@ -134,6 +151,7 @@ func (bot *Bot) initHandlers() { bot.b.Handle(tele.OnText, bot.handleText) bot.b.Handle(tele.OnPhoto, bot.handlePhoto) + bot.b.Handle(tele.OnDocument, bot.handleDocument) } func (bot *Bot) Start() { @@ -160,6 +178,11 @@ func (bot *Bot) handleStartCommand(c tele.Context) error { return bot.handleInviteLink(c, strings.TrimPrefix(payload, "invite_")) } + userID := c.Sender().ID + if bot.maintenanceMode && !bot.isDev(userID) { + return c.Send("🛠 **Сервис на техническом обслуживании** Мы проводим плановые работы... 📸 **Вы можете отправить фото чека или накладной** прямо в этот чат — наша команда обработает его и добавит в систему вручную.", tele.ModeHTML) + } + welcomeTxt := "🚀 RMSer — ваш умный ассистент для iiko\n\n" + "Больше не нужно вводить накладные вручную. Просто сфотографируйте чек, а я сделаю всё остальное.\n\n" + "Почему это удобно:\n" + @@ -443,6 +466,10 @@ func (bot *Bot) handleCallback(c tele.Context) error { } userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID) + userID := c.Sender().ID + if bot.maintenanceMode && !bot.isDev(userID) { + return c.Respond(&tele.CallbackResponse{Text: "Сервис на обслуживании"}) + } // --- INTEGRATION: Billing Callbacks --- if strings.HasPrefix(data, "bill_") || strings.HasPrefix(data, "buy_id_") || strings.HasPrefix(data, "pay_") { @@ -713,6 +740,10 @@ func (bot *Bot) handleText(c tele.Context) error { state := bot.fsm.GetState(userID) text := strings.TrimSpace(c.Text()) + if bot.maintenanceMode && !bot.isDev(userID) { + return c.Send("Сервис на обслуживании", tele.ModeHTML) + } + if strings.ToLower(text) == "отмена" || strings.ToLower(text) == "/cancel" { bot.fsm.Reset(userID) return bot.renderMainMenu(c) @@ -799,6 +830,7 @@ func (bot *Bot) handlePhoto(c tele.Context) error { if err != nil { return c.Send("Ошибка базы данных пользователей") } + userID := c.Sender().ID _, err = bot.rmsFactory.GetClientForUser(userDB.ID) if err != nil { return c.Send("⛔ Ошибка доступа к iiko или сервер не выбран.\nПроверьте статус сервера в меню.") @@ -821,7 +853,7 @@ func (bot *Bot) handlePhoto(c tele.Context) error { c.Send("⏳ ИИ анализирует документ...\nОбрабатываю позиции и ищу совпадения в вашей базе iiko.", tele.ModeHTML) ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) defer cancel() - draft, err := bot.ocrService.ProcessReceiptImage(ctx, userDB.ID, imgData) + draft, err := bot.ocrService.ProcessDocument(ctx, userDB.ID, imgData, "photo.jpg") if err != nil { logger.Log.Error("OCR processing failed", zap.Error(err)) return c.Send("❌ Ошибка обработки: " + err.Error()) @@ -832,23 +864,105 @@ func (bot *Bot) handlePhoto(c tele.Context) error { matchedCount++ } } - baseURL := strings.TrimRight(bot.webAppURL, "/") - fullURL := fmt.Sprintf("%s/invoice/%s", baseURL, draft.ID.String()) - var msgText string - if matchedCount == len(draft.Items) { - msgText = fmt.Sprintf("✅ Все позиции распознаны!\n\nВсе товары найдены в вашей номенклатуре. Проверьте количество (%d) и цену в приложении перед отправкой.", len(draft.Items)) + if bot.isDev(userID) { + baseURL := strings.TrimRight(bot.webAppURL, "/") + fullURL := fmt.Sprintf("%s/invoice/%s", baseURL, draft.ID.String()) + var msgText string + if matchedCount == len(draft.Items) { + msgText = fmt.Sprintf("✅ Все позиции распознаны!\n\nВсе товары найдены в вашей номенклатуре. Проверьте количество (%d) и цену в приложении перед отправкой.", len(draft.Items)) + } else { + msgText = fmt.Sprintf("⚠️ Распознано позиций: %d из %d\n\nОстальные товары я вижу впервые. Воспользуйтесь удобным интерфейсом сопоставления в приложении — я запомню ваш выбор навсегда.", matchedCount, len(draft.Items)) + } + menu := &tele.ReplyMarkup{} + role, _ := bot.accountRepo.GetUserRole(userDB.ID, draft.RMSServerID) + if role != account.RoleOperator { + btnOpen := menu.WebApp("📝 Открыть и сопоставить", &tele.WebApp{URL: fullURL}) + menu.Inline(menu.Row(btnOpen)) + } else { + msgText += "\n\n(Редактирование доступно Администратору)" + } + return c.Send(msgText, menu, tele.ModeHTML) } else { - msgText = fmt.Sprintf("⚠️ Распознано позиций: %d из %d\n\nОстальные товары я вижу впервые. Воспользуйтесь удобным интерфейсом сопоставления в приложении — я запомню ваш выбор навсегда.", matchedCount, len(draft.Items)) + return c.Send("✅ **Фото принято!** Мы получили ваш документ и передали его оператору. Спасибо!", tele.ModeHTML) } - menu := &tele.ReplyMarkup{} - role, _ := bot.accountRepo.GetUserRole(userDB.ID, draft.RMSServerID) - if role != account.RoleOperator { - btnOpen := menu.WebApp("📝 Открыть и сопоставить", &tele.WebApp{URL: fullURL}) - menu.Inline(menu.Row(btnOpen)) +} + +func (bot *Bot) handleDocument(c tele.Context) error { + userDB, err := bot.accountRepo.GetOrCreateUser(c.Sender().ID, c.Sender().Username, "", "") + if err != nil { + return c.Send("Ошибка базы данных пользователей") + } + userID := c.Sender().ID + _, err = bot.rmsFactory.GetClientForUser(userDB.ID) + if err != nil { + return c.Send("⛔ Ошибка доступа к iiko или сервер не выбран.\nПроверьте статус сервера в меню.") + } + + doc := c.Message().Document + filename := doc.FileName + + // Проверяем расширение файла + ext := strings.ToLower(filepath.Ext(filename)) + allowedExtensions := map[string]bool{ + ".jpg": true, + ".jpeg": true, + ".png": true, + ".xlsx": true, + } + if !allowedExtensions[ext] { + return c.Send("❌ Неподдерживаемый формат файла. Пожалуйста, отправьте изображение (.jpg, .jpeg, .png) или Excel файл (.xlsx).") + } + + file, err := bot.b.FileByID(doc.FileID) + if err != nil { + return c.Send("Ошибка доступа к файлу.") + } + fileURL := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", bot.b.Token, file.FilePath) + resp, err := http.Get(fileURL) + if err != nil { + return c.Send("Ошибка скачивания файла.") + } + defer resp.Body.Close() + fileData, err := io.ReadAll(resp.Body) + if err != nil { + return c.Send("Ошибка чтения файла.") + } + + c.Send("⏳ ИИ анализирует документ...\nОбрабатываю позиции и ищу совпадения в вашей базе iiko.", tele.ModeHTML) + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + draft, err := bot.ocrService.ProcessDocument(ctx, userDB.ID, fileData, filename) + if err != nil { + logger.Log.Error("OCR processing failed", zap.Error(err)) + return c.Send("❌ Ошибка обработки: " + err.Error()) + } + matchedCount := 0 + for _, item := range draft.Items { + if item.IsMatched { + matchedCount++ + } + } + if bot.isDev(userID) { + baseURL := strings.TrimRight(bot.webAppURL, "/") + fullURL := fmt.Sprintf("%s/invoice/%s", baseURL, draft.ID.String()) + var msgText string + if matchedCount == len(draft.Items) { + msgText = fmt.Sprintf("✅ Все позиции распознаны!\n\nВсе товары найдены в вашей номенклатуре. Проверьте количество (%d) и цену в приложении перед отправкой.", len(draft.Items)) + } else { + msgText = fmt.Sprintf("⚠️ Распознано позиций: %d из %d\n\nОстальные товары я вижу впервые. Воспользуйтесь удобным интерфейсом сопоставления в приложении — я запомню ваш выбор навсегда.", matchedCount, len(draft.Items)) + } + menu := &tele.ReplyMarkup{} + role, _ := bot.accountRepo.GetUserRole(userDB.ID, draft.RMSServerID) + if role != account.RoleOperator { + btnOpen := menu.WebApp("📝 Открыть и сопоставить", &tele.WebApp{URL: fullURL}) + menu.Inline(menu.Row(btnOpen)) + } else { + msgText += "\n\n(Редактирование доступно Администратору)" + } + return c.Send(msgText, menu, tele.ModeHTML) } else { - msgText += "\n\n(Редактирование доступно Администратору)" + return c.Send("✅ **Документ принят!** Мы получили ваш документ и передали его оператору. Спасибо!", tele.ModeHTML) } - return c.Send(msgText, menu, tele.ModeHTML) } func (bot *Bot) handleConfirmNameYes(c tele.Context) error { diff --git a/ocr-service/Dockerfile b/ocr-service/Dockerfile index 9b4d1b9..f0618ec 100644 --- a/ocr-service/Dockerfile +++ b/ocr-service/Dockerfile @@ -2,13 +2,10 @@ FROM python:3.10-slim # Установка системных зависимостей -# tesseract-ocr + rus: для распознавания текста # libgl1, libglib2.0-0: для работы OpenCV # libzbar0: для сканирования QR-кодов RUN apt-get update && apt-get install -y \ curl \ - tesseract-ocr \ - tesseract-ocr-rus \ libgl1 \ libglib2.0-0 \ libzbar0 \ @@ -31,4 +28,4 @@ COPY . . EXPOSE 5000 # Запускаем приложение -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "5000"] \ No newline at end of file +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "5000"] \ No newline at end of file diff --git a/ocr-service/main.py b/ocr-service/app/main.py similarity index 53% rename from ocr-service/main.py rename to ocr-service/app/main.py index d0ebf8d..06b632e 100644 --- a/ocr-service/main.py +++ b/ocr-service/app/main.py @@ -8,13 +8,12 @@ import cv2 import numpy as np # Импортируем модули -from imgproc import preprocess_image -from parser import parse_receipt_text, ParsedItem, extract_fiscal_data -from ocr import ocr_engine -from qr_manager import detect_and_decode_qr, fetch_data_from_api +from app.schemas.models import ParsedItem, RecognitionResult +from app.services.qr import detect_and_decode_qr, fetch_data_from_api, extract_fiscal_data # Импортируем новый модуль -from yandex_ocr import yandex_engine -from llm_parser import llm_parser +from app.services.ocr import yandex_engine +from app.services.llm import llm_parser +from app.services.excel import extract_text_from_excel logging.basicConfig( level=logging.INFO, @@ -22,12 +21,7 @@ logging.basicConfig( ) logger = logging.getLogger(__name__) -app = FastAPI(title="RMSER OCR Service (Hybrid: QR + Yandex + Tesseract)") - -class RecognitionResult(BaseModel): - source: str # 'qr_api', 'yandex_vision', 'tesseract_ocr' - items: List[ParsedItem] - raw_text: str = "" +app = FastAPI(title="RMSER OCR Service (Cloud-only: QR + Yandex + GigaChat)") @app.get("/health") def health_check(): @@ -37,20 +31,52 @@ def health_check(): async def recognize_receipt(image: UploadFile = File(...)): """ Стратегия: - 1. QR Code + FNS API (Приоритет 1 - Идеальная точность) - 2. Yandex Vision OCR (Приоритет 2 - Высокая точность, если настроен) - 3. Tesseract OCR (Приоритет 3 - Локальный фолбэк) + 1. Excel файл (.xlsx) -> Извлечение текста -> LLM парсинг + 2. QR Code + FNS API (Приоритет 1 - Идеальная точность) + 3. Yandex Vision OCR + LLM (Приоритет 2 - Высокая точность, если настроен) + Если ничего не найдено, возвращает пустой результат. """ logger.info(f"Received file: {image.filename}, content_type: {image.content_type}") + # Проверка на Excel файл + if image.filename and image.filename.lower().endswith('.xlsx'): + logger.info("Processing Excel file...") + try: + content = await image.read() + excel_text = extract_text_from_excel(content) + + if excel_text: + logger.info(f"Excel text extracted, length: {len(excel_text)}") + logger.info("Calling LLM Manager to parse Excel text...") + excel_result = llm_parser.parse_receipt(excel_text) + + return RecognitionResult( + source="excel_llm", + items=excel_result["items"], + raw_text=excel_text, + doc_number=excel_result["doc_number"], + doc_date=excel_result["doc_date"] + ) + else: + logger.warning("Excel file is empty or contains no text") + return RecognitionResult( + source="none", + items=[], + raw_text="" + ) + except Exception as e: + logger.error(f"Error processing Excel file: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Error processing Excel file: {str(e)}") + + # Проверка на изображение if not image.content_type.startswith("image/"): - raise HTTPException(status_code=400, detail="File must be an image") + raise HTTPException(status_code=400, detail="File must be an image or .xlsx file") try: # Читаем сырые байты content = await image.read() - # Конвертируем в numpy для QR и локального препроцессинга + # Конвертируем в numpy для QR nparr = np.frombuffer(content, np.uint8) original_cv_image = cv2.imdecode(nparr, cv2.IMREAD_COLOR) @@ -78,13 +104,13 @@ async def recognize_receipt(image: UploadFile = File(...)): logger.info("QR code not found. Proceeding to OCR.") # --- ЭТАП 2: OCR + Virtual QR Strategy --- - if yandex_engine.oauth_token and yandex_engine.folder_id: + if yandex_engine.is_configured(): logger.info("--- Stage 2: Yandex Vision OCR + Virtual QR ---") yandex_text = yandex_engine.recognize(content) - + if yandex_text and len(yandex_text) > 10: logger.info(f"OCR success. Raw text length: {len(yandex_text)}") - + # Попытка собрать виртуальный QR из текста virtual_qr = extract_fiscal_data(yandex_text) if virtual_qr: @@ -97,43 +123,32 @@ async def recognize_receipt(image: UploadFile = File(...)): items=api_items, raw_text=yandex_text ) - - # Если виртуальный QR не сработал, пробуем Regex - yandex_items = parse_receipt_text(yandex_text) - - # Если Regex пуст — вызываем LLM (GigaChat / YandexGPT) - if not yandex_items: - logger.info("Regex found nothing. Calling LLM Manager...") - iam_token = yandex_engine._get_iam_token() - yandex_items = llm_parser.parse_with_priority(yandex_text, iam_token) - + + # Вызываем LLM для парсинга текста + logger.info("Calling LLM Manager to parse text...") + yandex_result = llm_parser.parse_receipt(yandex_text) + return RecognitionResult( source="yandex_vision_llm", - items=yandex_items, - raw_text=yandex_text + items=yandex_result["items"], + raw_text=yandex_text, + doc_number=yandex_result["doc_number"], + doc_date=yandex_result["doc_date"] ) else: - logger.warning("Yandex Vision returned empty text or failed. Falling back to Tesseract.") + logger.warning("Yandex Vision returned empty text or failed. No fallback available.") + return RecognitionResult( + source="none", + items=[], + raw_text="" + ) else: - logger.info("Yandex Vision credentials not set. Skipping Stage 2.") - - # --- ЭТАП 3: Tesseract Strategy (Local Fallback) --- - logger.info("--- Stage 3: Tesseract OCR (Local) ---") - - # 1. Image Processing (бинаризация, выравнивание) - processed_img = preprocess_image(content) - - # 2. OCR - tesseract_text = ocr_engine.recognize(processed_img) - - # 3. Parsing - ocr_items = parse_receipt_text(tesseract_text) - - return RecognitionResult( - source="tesseract_ocr", - items=ocr_items, - raw_text=tesseract_text - ) + logger.info("Yandex Vision credentials not set. No OCR available.") + return RecognitionResult( + source="none", + items=[], + raw_text="" + ) except Exception as e: logger.error(f"Error processing request: {e}", exc_info=True) diff --git a/ocr-service/app/schemas/models.py b/ocr-service/app/schemas/models.py new file mode 100644 index 0000000..38c59a4 --- /dev/null +++ b/ocr-service/app/schemas/models.py @@ -0,0 +1,15 @@ +from typing import List +from pydantic import BaseModel + +class ParsedItem(BaseModel): + raw_name: str + amount: float + price: float + sum: float + +class RecognitionResult(BaseModel): + source: str # 'qr_api', 'virtual_qr_api', 'yandex_vision_llm', 'none' + items: List[ParsedItem] + raw_text: str = "" + doc_number: str = "" # Номер документа + doc_date: str = "" # Дата документа \ No newline at end of file diff --git a/ocr-service/app/services/auth.py b/ocr-service/app/services/auth.py new file mode 100644 index 0000000..41fae2a --- /dev/null +++ b/ocr-service/app/services/auth.py @@ -0,0 +1,68 @@ +import os +import time +import logging +import requests +from typing import Optional + +logger = logging.getLogger(__name__) + +IAM_TOKEN_URL = "https://iam.api.cloud.yandex.net/iam/v1/tokens" + +class YandexAuthManager: + def __init__(self): + self.oauth_token = os.getenv("YANDEX_OAUTH_TOKEN") + + # Кэширование IAM токена + self._iam_token = None + self._token_expire_time = 0 + + if not self.oauth_token: + logger.warning("YANDEX_OAUTH_TOKEN not set. Yandex services will be unavailable.") + + def is_configured(self) -> bool: + return bool(self.oauth_token) + + def reset_token(self): + """Сбрасывает кэшированный токен, заставляя получить новый при следующем вызове.""" + self._iam_token = None + self._token_expire_time = 0 + + def get_iam_token(self) -> Optional[str]: + """ + Получает IAM-токен. Если есть живой кэшированный — возвращает его. + Если нет — обменивает OAuth на IAM. + """ + current_time = time.time() + + # Если токен есть и он "свежий" (с запасом в 5 минут) + if self._iam_token and current_time < self._token_expire_time - 300: + return self._iam_token + + if not self.oauth_token: + logger.error("OAuth token not available.") + return None + + logger.info("Obtaining new IAM token from Yandex...") + try: + response = requests.post( + IAM_TOKEN_URL, + json={"yandexPassportOauthToken": self.oauth_token}, + timeout=10 + ) + response.raise_for_status() + data = response.json() + + self._iam_token = data["iamToken"] + + # Токен обычно живет 12 часов, но мы будем ориентироваться на поле expiresAt если нужно, + # или просто поставим таймер. Для простоты берем 1 час жизни кэша. + self._token_expire_time = current_time + 3600 + + logger.info("IAM token received successfully.") + return self._iam_token + except Exception as e: + logger.error(f"Failed to get IAM token: {e}") + return None + +# Глобальный инстанс +yandex_auth = YandexAuthManager() \ No newline at end of file diff --git a/ocr-service/app/services/excel.py b/ocr-service/app/services/excel.py new file mode 100644 index 0000000..8cb1737 --- /dev/null +++ b/ocr-service/app/services/excel.py @@ -0,0 +1,46 @@ +import io +import logging +import re +from openpyxl import load_workbook + +logger = logging.getLogger(__name__) + + +def extract_text_from_excel(content: bytes) -> str: + """ + Извлекает текстовое содержимое из Excel файла (.xlsx). + + Проходит по всем строкам активного листа, собирает значения ячеек + и формирует текстовую строку для передачи в LLM парсер. + + Args: + content: Байтовое содержимое Excel файла + + Returns: + Строка с текстовым представлением содержимого Excel файла + """ + try: + # Загружаем workbook из байтов, data_only=True берет значения, а не формулы + wb = load_workbook(filename=io.BytesIO(content), data_only=True) + sheet = wb.active + + lines = [] + for row in sheet.iter_rows(values_only=True): + # Собираем непустые значения в строку через разделитель + row_text = " | ".join([ + str(cell).strip() + for cell in row + if cell is not None and str(cell).strip() != "" + ]) + # Простая эвристика: строка должна содержать хотя бы одну букву (кириллица/латиница) И хотя бы одну цифру. + # Это отсеет пустые разделители и чистые заголовки. + if row_text and re.search(r'[a-zA-Zа-яА-Я]', row_text) and re.search(r'\d', row_text): + lines.append(row_text) + + result = "\n".join(lines) + logger.info(f"Extracted {len(lines)} lines from Excel file") + return result + + except Exception as e: + logger.error(f"Error extracting text from Excel: {e}", exc_info=True) + raise diff --git a/ocr-service/app/services/llm.py b/ocr-service/app/services/llm.py new file mode 100644 index 0000000..4cfe593 --- /dev/null +++ b/ocr-service/app/services/llm.py @@ -0,0 +1,237 @@ +import os +import requests +from requests.exceptions import SSLError +import logging +import json +import uuid +import time +import re +from abc import ABC, abstractmethod +from typing import List, Optional + +from app.schemas.models import ParsedItem +from app.services.auth import yandex_auth + +logger = logging.getLogger(__name__) + +YANDEX_GPT_URL = "https://llm.api.cloud.yandex.net/foundationModels/v1/completion" +GIGACHAT_OAUTH_URL = "https://ngw.devices.sberbank.ru:9443/api/v2/oauth" +GIGACHAT_COMPLETION_URL = "https://gigachat.devices.sberbank.ru/api/v1/chat/completions" + +class LLMProvider(ABC): + @abstractmethod + def generate(self, system_prompt: str, user_text: str) -> str: + pass + +class YandexGPTProvider(LLMProvider): + def __init__(self): + self.folder_id = os.getenv("YANDEX_FOLDER_ID") + + def generate(self, system_prompt: str, user_text: str) -> str: + iam_token = yandex_auth.get_iam_token() + if not iam_token: + raise Exception("Failed to get IAM token") + + prompt = { + "modelUri": f"gpt://{self.folder_id}/yandexgpt/latest", + "completionOptions": {"stream": False, "temperature": 0.1, "maxTokens": "2000"}, + "messages": [ + {"role": "system", "text": system_prompt}, + {"role": "user", "text": user_text} + ] + } + + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {iam_token}", + "x-folder-id": self.folder_id + } + + response = requests.post(YANDEX_GPT_URL, headers=headers, json=prompt, timeout=30) + response.raise_for_status() + content = response.json()['result']['alternatives'][0]['message']['text'] + return content + +class GigaChatProvider(LLMProvider): + def __init__(self): + self.auth_key = os.getenv("GIGACHAT_AUTH_KEY") + self._access_token = None + self._expires_at = 0 + + def _get_token(self) -> Optional[str]: + if self._access_token and time.time() < self._expires_at: + return self._access_token + + logger.info("Obtaining GigaChat access token...") + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + 'RqUID': str(uuid.uuid4()), + 'Authorization': f'Basic {self.auth_key}' + } + payload = {'scope': 'GIGACHAT_API_PERS'} + + response = requests.post(GIGACHAT_OAUTH_URL, headers=headers, data=payload, timeout=10) + response.raise_for_status() + data = response.json() + self._access_token = data['access_token'] + self._expires_at = data['expires_at'] / 1000 # Переводим мс в сек + return self._access_token + + def generate(self, system_prompt: str, user_text: str) -> str: + token = self._get_token() + if not token: + raise Exception("Failed to get GigaChat token") + + headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': f'Bearer {token}' + } + + payload = { + "model": "GigaChat", + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_text} + ], + "temperature": 0.1 + } + + try: + response = requests.post(GIGACHAT_COMPLETION_URL, headers=headers, json=payload, timeout=30) + response.raise_for_status() + content = response.json()['choices'][0]['message']['content'] + return content + except SSLError as e: + logger.error("SSL Error with GigaChat. Check certificates") + raise e + +class LLMManager: + def __init__(self): + engine = os.getenv("LLM_ENGINE", "yandex").lower() + self.engine = engine + if engine == "gigachat": + self.provider = GigaChatProvider() + else: + self.provider = YandexGPTProvider() + logger.info(f"LLM Engine initialized: {self.engine}") + + def parse_receipt(self, raw_text: str) -> dict: + system_prompt = """ +Ты — профессиональный бухгалтер. Твоя задача — извлечь из "сырого" текста (OCR или Excel) данные о товарах и реквизиты документа. + +ВХОДНЫЕ ДАННЫЕ: +Текст чека, накладной или УПД. В Excel-файлах колонки разделены символом "|". + +ФОРМАТ ОТВЕТА (JSON): +{ + "doc_number": "Номер документа (или ФД для чеков)", + "doc_date": "Дата документа (DD.MM.YYYY)", + "items": [ + { + "raw_name": "Название товара", + "amount": 1.0, + "price": 100.0, // Цена за единицу (с учетом скидок) + "sum": 100.0 // Стоимость позиции (ВСЕГО с НДС) + } + ] +} + +ПРАВИЛА ПОИСКА РЕКВИЗИТОВ: +1. Номер документа: Ищи "Счет-фактура №", "УПД №", "Накладная №", "ФД:", "Документ №". + - Пример: "УПД № 556430/123" -> "556430/123" + - Пример: "ФД: 12345" -> "12345" +2. Дата: Ищи рядом с номером. Преобразуй в формат DD.MM.YYYY. + +ОБЩИЕ ПРАВИЛА ОБРАБОТКИ ТОВАРОВ: +1. **ЗАПРЕТ ГРУППИРОВКИ:** Если в чеке 5 раз подряд идет "Масло сливочное" (даже с разными кодами), ты должен вернуть **5 отдельных объектов**. НИКОГДА не объединяй их и не суммируй количество, если это не указано явно в одной строке (типа "5 шт x 100"). +2. **ОЧИСТКА:** Игнорируй строки "ИТОГ", "СУММА", "НДС", "Всего к оплате", "Грузоотправитель", "Продавец". Числа приводи к float. + +СТРАТЕГИЯ ДЛЯ EXCEL (УПД): +1. Разделитель колонок — "|". +2. Название — самая длинная текстовая ячейка. +3. Сумма — колонка "Стоимость с налогом - всего" (обычно крайняя правая сумма в строке). Если есть "без налога" и "с налогом", бери С НАЛОГОМ. + +СТРАТЕГИЯ ДЛЯ ЧЕКОВ (OCR): +1. **МАРКЕР НАЧАЛА:** Часто строка товара начинается с цифрового кода (артикула). Пример: `6328 Масло...`. Если видишь число в начале строки, за которым идет текст — это НАЧАЛО нового товара. +2. **МНОГОСТРОЧНОСТЬ:** Название товара может быть разбито на 2-4 строки. + - Если видишь строку с цифрами (цена, кол-во), а перед ней текст — это конец описания товара. Склей текст с предыдущими строками. + - Пример: + `6298 Масло` + `ТРАД.сл.` + `1 шт 174.24` + -> Название: "6298 Масло ТРАД.сл." +3. **ЦЕНА:** Если указана "Цена со скидкой", бери её. + +ПРИМЕР 1 (УПД): +Вход: +Код | Товар | Кол-во | Цена | Сумма без НДС | Сумма с НДС +1 | Сок ананасовый | 4 | 533.64 | 2134.56 | 2348.00 +Выход: +{ + "doc_number": "", "doc_date": "", + "items": [ + {"raw_name": "Сок ананасовый", "amount": 4.0, "price": 533.64, "sum": 2348.00} + ] +} + +ПРИМЕР 2 (Сложный чек): +Вход: +5603 СЫР ПЛАВ. +45% 200Г +169.99 1шт 169.99 +6328 Масло ТРАД. +Сл.82.5% 175г +204.99 30.75 174.24 1шт 174.24 +6298 Масло ТРАД. +Сл.82.5% +175г +1шт 174.24 +Выход: +{ + "doc_number": "", "doc_date": "", + "items": [ + {"raw_name": "5603 СЫР ПЛАВ. 45% 200Г", "amount": 1.0, "price": 169.99, "sum": 169.99}, + {"raw_name": "6328 Масло ТРАД. Сл.82.5% 175г", "amount": 1.0, "price": 174.24, "sum": 174.24}, + {"raw_name": "6298 Масло ТРАД. Сл.82.5% 175г", "amount": 1.0, "price": 174.24, "sum": 174.24} + ] +} +""" + + try: + logger.info(f"Parsing receipt using engine: {self.engine}") + response = self.provider.generate(system_prompt, raw_text) + # Очистка от Markdown + clean_json = response.replace("```json", "").replace("```", "").strip() + # Парсинг JSON + data = json.loads(clean_json) + + # Обработка обратной совместимости: если вернулся список, оборачиваем в словарь + if isinstance(data, list): + data = {"items": data, "doc_number": "", "doc_date": ""} + + # Извлекаем товары + items_data = data.get("items", []) + + # Пост-обработка: исправление чисел в товарах + for item in items_data: + for key in ['amount', 'price', 'sum']: + if isinstance(item[key], str): + # Удаляем пробелы внутри чисел + item[key] = float(re.sub(r'\s+', '', item[key]).replace(',', '.')) + elif isinstance(item[key], (int, float)): + item[key] = float(item[key]) + + # Формируем результат + result = { + "items": [ParsedItem(**item) for item in items_data], + "doc_number": data.get("doc_number", ""), + "doc_date": data.get("doc_date", "") + } + return result + except Exception as e: + logger.error(f"LLM Parsing error: {e}") + return {"items": [], "doc_number": "", "doc_date": ""} + +llm_parser = LLMManager() \ No newline at end of file diff --git a/ocr-service/yandex_ocr.py b/ocr-service/app/services/ocr.py similarity index 65% rename from ocr-service/yandex_ocr.py rename to ocr-service/app/services/ocr.py index 6d8417b..505887b 100644 --- a/ocr-service/yandex_ocr.py +++ b/ocr-service/app/services/ocr.py @@ -1,70 +1,47 @@ +from abc import ABC, abstractmethod +from typing import Optional import os -import time import json import base64 import logging import requests -from typing import Optional + +from app.services.auth import yandex_auth logger = logging.getLogger(__name__) -IAM_TOKEN_URL = "https://iam.api.cloud.yandex.net/iam/v1/tokens" VISION_URL = "https://ocr.api.cloud.yandex.net/ocr/v1/recognizeText" -class YandexOCREngine: - def __init__(self): - self.oauth_token = os.getenv("YANDEX_OAUTH_TOKEN") - self.folder_id = os.getenv("YANDEX_FOLDER_ID") - - # Кэширование IAM токена - self._iam_token = None - self._token_expire_time = 0 +class OCREngine(ABC): + @abstractmethod + def recognize(self, image_bytes: bytes) -> str: + """Распознает текст из изображения и возвращает строку.""" + pass - if not self.oauth_token or not self.folder_id: + @abstractmethod + def is_configured(self) -> bool: + """Проверяет, настроен ли движок (наличие ключей/настроек).""" + pass + +class YandexOCREngine(OCREngine): + def __init__(self): + self.folder_id = os.getenv("YANDEX_FOLDER_ID") + + if not yandex_auth.is_configured() or not self.folder_id: logger.warning("Yandex OCR credentials (YANDEX_OAUTH_TOKEN, YANDEX_FOLDER_ID) not set. Yandex OCR will be unavailable.") - def _get_iam_token(self) -> Optional[str]: - """ - Получает IAM-токен. Если есть живой кэшированный — возвращает его. - Если нет — обменивает OAuth на IAM. - """ - current_time = time.time() - - # Если токен есть и он "свежий" (с запасом в 5 минут) - if self._iam_token and current_time < self._token_expire_time - 300: - return self._iam_token - - logger.info("Obtaining new IAM token from Yandex...") - try: - response = requests.post( - IAM_TOKEN_URL, - json={"yandexPassportOauthToken": self.oauth_token}, - timeout=10 - ) - response.raise_for_status() - data = response.json() - - self._iam_token = data["iamToken"] - - # Токен обычно живет 12 часов, но мы будем ориентироваться на поле expiresAt если нужно, - # или просто поставим таймер. Для простоты берем 1 час жизни кэша. - self._token_expire_time = current_time + 3600 - - logger.info("IAM token received successfully.") - return self._iam_token - except Exception as e: - logger.error(f"Failed to get IAM token: {e}") - return None + def is_configured(self) -> bool: + return yandex_auth.is_configured() and bool(self.folder_id) def recognize(self, image_bytes: bytes) -> str: """ Отправляет изображение в Yandex Vision и возвращает полный текст. """ - if not self.oauth_token or not self.folder_id: + if not self.is_configured(): logger.error("Yandex credentials missing.") return "" - iam_token = self._get_iam_token() + iam_token = yandex_auth.get_iam_token() if not iam_token: return "" @@ -96,8 +73,8 @@ class YandexOCREngine: # Если 401 Unauthorized, возможно токен протух раньше времени (редко, но бывает) if response.status_code == 401: logger.warning("Got 401 from Yandex. Retrying with fresh token...") - self._iam_token = None # сброс кэша - iam_token = self._get_iam_token() + yandex_auth.reset_token() # сброс кэша + iam_token = yandex_auth.get_iam_token() if iam_token: headers["Authorization"] = f"Bearer {iam_token}" response = requests.post(VISION_URL, headers=headers, json=payload, timeout=20) diff --git a/ocr-service/qr_manager.py b/ocr-service/app/services/qr.py similarity index 65% rename from ocr-service/qr_manager.py rename to ocr-service/app/services/qr.py index 323765b..0c37580 100644 --- a/ocr-service/qr_manager.py +++ b/ocr-service/app/services/qr.py @@ -1,18 +1,65 @@ import logging import requests +import re from typing import Optional, List from pyzbar.pyzbar import decode from PIL import Image import numpy as np -# Импортируем модель из parser.py -from parser import ParsedItem +from app.schemas.models import ParsedItem API_TOKEN = "36590.yqtiephCvvkYUKM2W" API_URL = "https://proverkacheka.com/api/v1/check/get" logger = logging.getLogger(__name__) +def extract_fiscal_data(text: str) -> Optional[str]: + """ + Ищет в тексте: + - Дата и время (t): 19.12.25 12:16 -> 20251219T1216 + - Сумма (s): ИТОГ: 770.00 + - ФН (fn): 16 цифр, начинается на 73 + - ФД (i): до 8 цифр (ищем после Д: или ФД:) + - ФП (fp): 10 цифр (ищем после П: или ФП:) + + Возвращает строку формата: t=20251219T1216&s=770.00&fn=7384440800514469&i=11194&fp=3334166168&n=1 + """ + # 1. Поиск даты и времени + # Ищем форматы DD.MM.YY HH:MM или DD.MM.YYYY HH:MM + date_match = re.search(r'(\d{2})\.(\d{2})\.(\d{2,4})\s+(\d{2}):(\d{2})', text) + t_param = "" + if date_match: + d, m, y, hh, mm = date_match.groups() + if len(y) == 2: y = "20" + y + t_param = f"{y}{m}{d}T{hh}{mm}" + + # 2. Поиск суммы (Итог) + # Ищем слово ИТОГ и число после него + sum_match = re.search(r'(?:ИТОГ|СУММА|СУММА:)\s*[:]*\s*(\d+[.,]\d{2})', text, re.IGNORECASE) + s_param = "" + if sum_match: + s_param = sum_match.group(1).replace(',', '.') + + # 3. Поиск ФН (16 цифр, начинается с 73) + fn_match = re.search(r'\b(73\d{14})\b', text) + fn_param = fn_match.group(1) if fn_match else "" + + # 4. Поиск ФД (i) - ищем после маркеров Д: или ФД: + # Берем набор цифр до 8 знаков + fd_match = re.search(r'(?:ФД|Д)[:\s]+(\d{1,8})\b', text) + i_param = fd_match.group(1) if fd_match else "" + + # 5. Поиск ФП (fp) - ищем после маркеров П: или ФП: + # Строго 10 цифр + fp_match = re.search(r'(?:ФП|П)[:\s]+(\d{10})\b', text) + fp_param = fp_match.group(1) if fp_match else "" + + # Валидация: для формирования запроса к API нам критически важны все параметры + if all([t_param, s_param, fn_param, i_param, fp_param]): + return f"t={t_param}&s={s_param}&fn={fn_param}&i={i_param}&fp={fp_param}&n=1" + + return None + def is_valid_fiscal_qr(qr_string: str) -> bool: """ Проверяет, соответствует ли строка формату фискального чека ФНС. diff --git a/ocr-service/imgproc.py b/ocr-service/imgproc.py deleted file mode 100644 index ca0ea62..0000000 --- a/ocr-service/imgproc.py +++ /dev/null @@ -1,97 +0,0 @@ -import cv2 -import numpy as np -import logging - -logger = logging.getLogger(__name__) - -def order_points(pts): - rect = np.zeros((4, 2), dtype="float32") - s = pts.sum(axis=1) - rect[0] = pts[np.argmin(s)] - rect[2] = pts[np.argmax(s)] - diff = np.diff(pts, axis=1) - rect[1] = pts[np.argmin(diff)] - rect[3] = pts[np.argmax(diff)] - return rect - -def four_point_transform(image, pts): - rect = order_points(pts) - (tl, tr, br, bl) = rect - - widthA = np.sqrt(((br[0] - bl[0]) ** 2) + ((br[1] - bl[1]) ** 2)) - widthB = np.sqrt(((tr[0] - tl[0]) ** 2) + ((tr[1] - tl[1]) ** 2)) - maxWidth = max(int(widthA), int(widthB)) - - heightA = np.sqrt(((tr[0] - br[0]) ** 2) + ((tr[1] - br[1]) ** 2)) - heightB = np.sqrt(((tl[0] - bl[0]) ** 2) + ((tl[1] - bl[1]) ** 2)) - maxHeight = max(int(heightA), int(heightB)) - - dst = np.array([ - [0, 0], - [maxWidth - 1, 0], - [maxWidth - 1, maxHeight - 1], - [0, maxHeight - 1]], dtype="float32") - - M = cv2.getPerspectiveTransform(rect, dst) - warped = cv2.warpPerspective(image, M, (maxWidth, maxHeight)) - return warped - -def preprocess_image(image_bytes: bytes) -> np.ndarray: - """ - Возвращает БИНАРНОЕ (Ч/Б) изображение для Tesseract. - """ - nparr = np.frombuffer(image_bytes, np.uint8) - image = cv2.imdecode(nparr, cv2.IMREAD_COLOR) - - if image is None: - raise ValueError("Could not decode image") - - # Ресайз для поиска контуров - ratio = image.shape[0] / 500.0 - orig = image.copy() - image_small = cv2.resize(image, (int(image.shape[1] / ratio), 500)) - - gray = cv2.cvtColor(image_small, cv2.COLOR_BGR2GRAY) - gray = cv2.GaussianBlur(gray, (5, 5), 0) - edged = cv2.Canny(gray, 75, 200) - - cnts, _ = cv2.findContours(edged.copy(), cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE) - cnts = sorted(cnts, key=cv2.contourArea, reverse=True)[:5] - - screenCnt = None - found = False - - for c in cnts: - peri = cv2.arcLength(c, True) - approx = cv2.approxPolyDP(c, 0.02 * peri, True) - if len(approx) == 4: - screenCnt = approx - found = True - break - - # Изображение, с которым будем работать дальше - target_img = None - - if found: - logger.info("Receipt contour found (Tesseract mode).") - target_img = four_point_transform(orig, screenCnt.reshape(4, 2) * ratio) - else: - logger.warning("Receipt contour NOT found. Using full image.") - target_img = orig - - # --- Подготовка для Tesseract (Бинаризация) --- - # Переводим в Gray - gray_final = cv2.cvtColor(target_img, cv2.COLOR_BGR2GRAY) - - # Адаптивный порог (превращаем в чисто черное и белое) - # block_size=11, C=2 - классические параметры для текста - thresh = cv2.adaptiveThreshold( - gray_final, 255, - cv2.ADAPTIVE_THRESH_GAUSSIAN_C, - cv2.THRESH_BINARY, 11, 2 - ) - - # Немного убираем шум - # thresh = cv2.medianBlur(thresh, 3) - - return thresh \ No newline at end of file diff --git a/ocr-service/llm_parser.py b/ocr-service/llm_parser.py deleted file mode 100644 index 92b54ed..0000000 --- a/ocr-service/llm_parser.py +++ /dev/null @@ -1,150 +0,0 @@ -import os -import requests -import logging -import json -import uuid -import time -from typing import List, Optional -from parser import ParsedItem - -logger = logging.getLogger(__name__) - -YANDEX_GPT_URL = "https://llm.api.cloud.yandex.net/foundationModels/v1/completion" -GIGACHAT_OAUTH_URL = "https://ngw.devices.sberbank.ru:9443/api/v2/oauth" -GIGACHAT_COMPLETION_URL = "https://gigachat.devices.sberbank.ru/api/v1/chat/completions" - -class YandexGPTParser: - def __init__(self): - self.folder_id = os.getenv("YANDEX_FOLDER_ID") - self.api_key = os.getenv("YANDEX_OAUTH_TOKEN") - - def parse(self, raw_text: str, iam_token: str) -> List[ParsedItem]: - if not iam_token: - return [] - - prompt = { - "modelUri": f"gpt://{self.folder_id}/yandexgpt/latest", - "completionOptions": {"stream": False, "temperature": 0.1, "maxTokens": "2000"}, - "messages": [ - { - "role": "system", - "text": ( - "Ты — помощник по бухгалтерии. Извлеки список товаров из текста документа. " - "Верни ответ строго в формате JSON: " - '[{"raw_name": string, "amount": float, "price": float, "sum": float}]. ' - "Если количество не указано, считай 1.0. Не пиши ничего, кроме JSON." - ) - }, - {"role": "user", "text": raw_text} - ] - } - - headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {iam_token}", - "x-folder-id": self.folder_id - } - - try: - response = requests.post(YANDEX_GPT_URL, headers=headers, json=prompt, timeout=30) - response.raise_for_status() - content = response.json()['result']['alternatives'][0]['message']['text'] - clean_json = content.replace("```json", "").replace("```", "").strip() - return [ParsedItem(**item) for item in json.loads(clean_json)] - except Exception as e: - logger.error(f"YandexGPT Parsing error: {e}") - return [] - -class GigaChatParser: - def __init__(self): - self.auth_key = os.getenv("GIGACHAT_AUTH_KEY") - self._access_token = None - self._expires_at = 0 - - def _get_token(self) -> Optional[str]: - if self._access_token and time.time() < self._expires_at: - return self._access_token - - logger.info("Obtaining GigaChat access token...") - headers = { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Accept': 'application/json', - 'RqUID': str(uuid.uuid4()), - 'Authorization': f'Basic {self.auth_key}' - } - payload = {'scope': 'GIGACHAT_API_PERS'} - - try: - # verify=False может понадобиться, если сертификаты Минцифры не в системном хранилище, - # но вы указали, что установите их в контейнер. - response = requests.post(GIGACHAT_OAUTH_URL, headers=headers, data=payload, timeout=10) - response.raise_for_status() - data = response.json() - self._access_token = data['access_token'] - self._expires_at = data['expires_at'] / 1000 # Переводим мс в сек - return self._access_token - except Exception as e: - logger.error(f"GigaChat Auth error: {e}") - return None - - def parse(self, raw_text: str) -> List[ParsedItem]: - token = self._get_token() - if not token: - return [] - - headers = { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'Authorization': f'Bearer {token}' - } - - payload = { - "model": "GigaChat", - "messages": [ - { - "role": "system", - "content": ( - "Ты — эксперт по распознаванию чеков. Извлеки товары из текста. " - "Верни ТОЛЬКО JSON массив объектов с полями: raw_name (строка), " - "amount (число), price (число), sum (число). " - "Если данных нет, верни []. Никаких пояснений." - ) - }, - {"role": "user", "content": raw_text} - ], - "temperature": 0.1 - } - - try: - response = requests.post(GIGACHAT_COMPLETION_URL, headers=headers, json=payload, timeout=30) - response.raise_for_status() - content = response.json()['choices'][0]['message']['content'] - clean_json = content.replace("```json", "").replace("```", "").strip() - return [ParsedItem(**item) for item in json.loads(clean_json)] - except Exception as e: - logger.error(f"GigaChat Parsing error: {e}") - return [] - -class LLMManager: - def __init__(self): - self.yandex = YandexGPTParser() - self.giga = GigaChatParser() - self.engine = os.getenv("LLM_ENGINE", "yandex").lower() - - def parse_with_priority(self, raw_text: str, yandex_iam_token: Optional[str] = None) -> List[ParsedItem]: - if self.engine == "gigachat": - logger.info("Using GigaChat as primary LLM") - items = self.giga.parse(raw_text) - if not items and yandex_iam_token: - logger.info("GigaChat failed, falling back to YandexGPT") - items = self.yandex.parse(raw_text, yandex_iam_token) - return items - else: - logger.info("Using YandexGPT as primary LLM") - items = self.yandex.parse(raw_text, yandex_iam_token) if yandex_iam_token else [] - if not items: - logger.info("YandexGPT failed, falling back to GigaChat") - items = self.giga.parse(raw_text) - return items - -llm_parser = LLMManager() \ No newline at end of file diff --git a/ocr-service/ocr.py b/ocr-service/ocr.py deleted file mode 100644 index db9e44f..0000000 --- a/ocr-service/ocr.py +++ /dev/null @@ -1,38 +0,0 @@ -import logging -import pytesseract -from PIL import Image -import numpy as np - -logger = logging.getLogger(__name__) - -# Если tesseract не в PATH, раскомментируй и укажи путь: -# pytesseract.pytesseract.tesseract_cmd = r'/usr/bin/tesseract' - -class OCREngine: - def __init__(self): - logger.info("Initializing Tesseract OCR wrapper...") - # Tesseract не требует загрузки моделей в память, - # проверка версии просто чтобы убедиться, что он установлен - try: - version = pytesseract.get_tesseract_version() - logger.info(f"Tesseract version found: {version}") - except Exception as e: - logger.error("Tesseract not found! Make sure it is installed (apt install tesseract-ocr).") - raise e - - def recognize(self, image: np.ndarray) -> str: - """ - Принимает бинарное изображение (numpy array). - """ - # Tesseract работает лучше с PIL Image - pil_img = Image.fromarray(image) - - # Конфигурация: - # -l rus+eng: русский и английский - # --psm 6: Assume a single uniform block of text (хорошо для чеков) - custom_config = r'--oem 3 --psm 6' - - text = pytesseract.image_to_string(pil_img, lang='rus+eng', config=custom_config) - return text - -ocr_engine = OCREngine() \ No newline at end of file diff --git a/ocr-service/parser.py b/ocr-service/parser.py deleted file mode 100644 index cbcfa59..0000000 --- a/ocr-service/parser.py +++ /dev/null @@ -1,132 +0,0 @@ -import re -from typing import List, Optional -from pydantic import BaseModel -from datetime import datetime - -class ParsedItem(BaseModel): - raw_name: str - amount: float - price: float - sum: float - -# Регулярка для поиска чисел с плавающей точкой: 123.00, 123,00, 10.5 -FLOAT_RE = r'\d+[.,]\d{2}' - -def clean_text(text: str) -> str: - """Удаляет лишние символы из названия товара.""" - return re.sub(r'[^\w\s.,%/-]', '', text).strip() - -def parse_float(val: str) -> float: - """Преобразует строку '123,45' или '123.45' в float.""" - if not val: - return 0.0 - return float(val.replace(',', '.').replace(' ', '')) - -def extract_fiscal_data(text: str) -> Optional[str]: - """ - Ищет в тексте: - - Дата и время (t): 19.12.25 12:16 -> 20251219T1216 - - Сумма (s): ИТОГ: 770.00 - - ФН (fn): 16 цифр, начинается на 73 - - ФД (i): до 8 цифр (ищем после Д: или ФД:) - - ФП (fp): 10 цифр (ищем после П: или ФП:) - - Возвращает строку формата: t=20251219T1216&s=770.00&fn=7384440800514469&i=11194&fp=3334166168&n=1 - """ - # 1. Поиск даты и времени - # Ищем форматы DD.MM.YY HH:MM или DD.MM.YYYY HH:MM - date_match = re.search(r'(\d{2})\.(\d{2})\.(\d{2,4})\s+(\d{2}):(\d{2})', text) - t_param = "" - if date_match: - d, m, y, hh, mm = date_match.groups() - if len(y) == 2: y = "20" + y - t_param = f"{y}{m}{d}T{hh}{mm}" - - # 2. Поиск суммы (Итог) - # Ищем слово ИТОГ и число после него - sum_match = re.search(r'(?:ИТОГ|СУММА|СУММА:)\s*[:]*\s*(\d+[.,]\d{2})', text, re.IGNORECASE) - s_param = "" - if sum_match: - s_param = sum_match.group(1).replace(',', '.') - - # 3. Поиск ФН (16 цифр, начинается с 73) - fn_match = re.search(r'\b(73\d{14})\b', text) - fn_param = fn_match.group(1) if fn_match else "" - - # 4. Поиск ФД (i) - ищем после маркеров Д: или ФД: - # Берем набор цифр до 8 знаков - fd_match = re.search(r'(?:ФД|Д)[:\s]+(\d{1,8})\b', text) - i_param = fd_match.group(1) if fd_match else "" - - # 5. Поиск ФП (fp) - ищем после маркеров П: или ФП: - # Строго 10 цифр - fp_match = re.search(r'(?:ФП|П)[:\s]+(\d{10})\b', text) - fp_param = fp_match.group(1) if fp_match else "" - - # Валидация: для формирования запроса к API нам критически важны все параметры - if all([t_param, s_param, fn_param, i_param, fp_param]): - return f"t={t_param}&s={s_param}&fn={fn_param}&i={i_param}&fp={fp_param}&n=1" - - return None - -def parse_receipt_text(text: str) -> List[ParsedItem]: - """ - Парсит текст чека построчно (Regex-метод). - """ - lines = text.split('\n') - items = [] - name_buffer = [] - - for line in lines: - line = line.strip() - if not line: - continue - - floats = re.findall(FLOAT_RE, line) - is_price_line = False - - if len(floats) >= 2: - is_price_line = True - vals = [parse_float(f) for f in floats] - - price = 0.0 - amount = 1.0 - total = vals[-1] - - if len(vals) == 2: - price = vals[0] - amount = 1.0 - if total > price and price > 0: - calc_amount = total / price - if abs(round(calc_amount) - calc_amount) < 0.05: - amount = float(round(calc_amount)) - elif len(vals) >= 3: - v1, v2 = vals[-3], vals[-2] - if abs(v1 * v2 - total) < 0.5: - price, amount = v1, v2 - elif abs(v2 * v1 - total) < 0.5: - price, amount = v2, v1 - else: - price, amount = vals[-2], 1.0 - - full_name = " ".join(name_buffer).strip() - if not full_name: - text_without_floats = re.sub(FLOAT_RE, '', line) - full_name = clean_text(text_without_floats) - - if len(full_name) > 2 and total > 0: - items.append(ParsedItem( - raw_name=full_name, - amount=amount, - price=price, - sum=total - )) - name_buffer = [] - else: - upper_line = line.upper() - if any(stop in upper_line for stop in ["ИТОГ", "СУММА", "ПРИХОД"]): - name_buffer = [] - continue - name_buffer.append(line) - - return items \ No newline at end of file diff --git a/ocr-service/python_project_dump.py b/ocr-service/python_project_dump.py index 697c281..9ffda2b 100644 --- a/ocr-service/python_project_dump.py +++ b/ocr-service/python_project_dump.py @@ -5,24 +5,31 @@ project_tree = ''' ocr-service/ ├── .dockerignore +├── .env ├── Dockerfile -├── imgproc.py -├── llm_parser.py -├── main.py -├── ocr.py -├── parser.py -├── qr_manager.py +├── app +│ ├── main.py +│ ├── schemas +│ │ └── models.py +│ └── services +│ ├── auth.py +│ ├── llm.py +│ ├── ocr.py +│ └── qr.py ├── requirements.txt -└── yandex_ocr.py +└── scripts + ├── collect_data_raw.py + └── test_parsing_quality.py ''' project_files = { - "imgproc.py": "import cv2\nimport numpy as np\nimport logging\n\nlogger = logging.getLogger(__name__)\n\ndef order_points(pts):\n rect = np.zeros((4, 2), dtype=\"float32\")\n s = pts.sum(axis=1)\n rect[0] = pts[np.argmin(s)]\n rect[2] = pts[np.argmax(s)]\n diff = np.diff(pts, axis=1)\n rect[1] = pts[np.argmin(diff)]\n rect[3] = pts[np.argmax(diff)]\n return rect\n\ndef four_point_transform(image, pts):\n rect = order_points(pts)\n (tl, tr, br, bl) = rect\n\n widthA = np.sqrt(((br[0] - bl[0]) ** 2) + ((br[1] - bl[1]) ** 2))\n widthB = np.sqrt(((tr[0] - tl[0]) ** 2) + ((tr[1] - tl[1]) ** 2))\n maxWidth = max(int(widthA), int(widthB))\n\n heightA = np.sqrt(((tr[0] - br[0]) ** 2) + ((tr[1] - br[1]) ** 2))\n heightB = np.sqrt(((tl[0] - bl[0]) ** 2) + ((tl[1] - bl[1]) ** 2))\n maxHeight = max(int(heightA), int(heightB))\n\n dst = np.array([\n [0, 0],\n [maxWidth - 1, 0],\n [maxWidth - 1, maxHeight - 1],\n [0, maxHeight - 1]], dtype=\"float32\")\n\n M = cv2.getPerspectiveTransform(rect, dst)\n warped = cv2.warpPerspective(image, M, (maxWidth, maxHeight))\n return warped\n\ndef preprocess_image(image_bytes: bytes) -> np.ndarray:\n \"\"\"\n Возвращает БИНАРНОЕ (Ч/Б) изображение для Tesseract.\n \"\"\"\n nparr = np.frombuffer(image_bytes, np.uint8)\n image = cv2.imdecode(nparr, cv2.IMREAD_COLOR)\n \n if image is None:\n raise ValueError(\"Could not decode image\")\n\n # Ресайз для поиска контуров\n ratio = image.shape[0] / 500.0\n orig = image.copy()\n image_small = cv2.resize(image, (int(image.shape[1] / ratio), 500))\n\n gray = cv2.cvtColor(image_small, cv2.COLOR_BGR2GRAY)\n gray = cv2.GaussianBlur(gray, (5, 5), 0)\n edged = cv2.Canny(gray, 75, 200)\n\n cnts, _ = cv2.findContours(edged.copy(), cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)\n cnts = sorted(cnts, key=cv2.contourArea, reverse=True)[:5]\n\n screenCnt = None\n found = False\n\n for c in cnts:\n peri = cv2.arcLength(c, True)\n approx = cv2.approxPolyDP(c, 0.02 * peri, True)\n if len(approx) == 4:\n screenCnt = approx\n found = True\n break\n\n # Изображение, с которым будем работать дальше\n target_img = None\n\n if found:\n logger.info(\"Receipt contour found (Tesseract mode).\")\n target_img = four_point_transform(orig, screenCnt.reshape(4, 2) * ratio)\n else:\n logger.warning(\"Receipt contour NOT found. Using full image.\")\n target_img = orig\n\n # --- Подготовка для Tesseract (Бинаризация) ---\n # Переводим в Gray\n gray_final = cv2.cvtColor(target_img, cv2.COLOR_BGR2GRAY)\n \n # Адаптивный порог (превращаем в чисто черное и белое)\n # block_size=11, C=2 - классические параметры для текста\n thresh = cv2.adaptiveThreshold(\n gray_final, 255, \n cv2.ADAPTIVE_THRESH_GAUSSIAN_C, \n cv2.THRESH_BINARY, 11, 2\n )\n \n # Немного убираем шум\n # thresh = cv2.medianBlur(thresh, 3) \n \n return thresh", - "llm_parser.py": "import os\nimport requests\nimport logging\nimport json\nimport uuid\nimport time\nfrom typing import List, Optional\nfrom parser import ParsedItem\n\nlogger = logging.getLogger(__name__)\n\nYANDEX_GPT_URL = \"https://llm.api.cloud.yandex.net/foundationModels/v1/completion\"\nGIGACHAT_OAUTH_URL = \"https://ngw.devices.sberbank.ru:9443/api/v2/oauth\"\nGIGACHAT_COMPLETION_URL = \"https://gigachat.devices.sberbank.ru/api/v1/chat/completions\"\n\nclass YandexGPTParser:\n def __init__(self):\n self.folder_id = os.getenv(\"YANDEX_FOLDER_ID\")\n self.api_key = os.getenv(\"YANDEX_OAUTH_TOKEN\")\n\n def parse(self, raw_text: str, iam_token: str) -> List[ParsedItem]:\n if not iam_token:\n return []\n \n prompt = {\n \"modelUri\": f\"gpt://{self.folder_id}/yandexgpt/latest\",\n \"completionOptions\": {\"stream\": False, \"temperature\": 0.1, \"maxTokens\": \"2000\"},\n \"messages\": [\n {\n \"role\": \"system\",\n \"text\": (\n \"Ты — помощник по бухгалтерии. Извлеки список товаров из текста документа. \"\n \"Верни ответ строго в формате JSON: \"\n '[{\"raw_name\": string, \"amount\": float, \"price\": float, \"sum\": float}]. '\n \"Если количество не указано, считай 1.0. Не пиши ничего, кроме JSON.\"\n )\n },\n {\"role\": \"user\", \"text\": raw_text}\n ]\n }\n\n headers = {\n \"Content-Type\": \"application/json\",\n \"Authorization\": f\"Bearer {iam_token}\",\n \"x-folder-id\": self.folder_id\n }\n\n try:\n response = requests.post(YANDEX_GPT_URL, headers=headers, json=prompt, timeout=30)\n response.raise_for_status()\n content = response.json()['result']['alternatives'][0]['message']['text']\n clean_json = content.replace(\"```json\", \"\").replace(\"```\", \"\").strip()\n return [ParsedItem(**item) for item in json.loads(clean_json)]\n except Exception as e:\n logger.error(f\"YandexGPT Parsing error: {e}\")\n return []\n\nclass GigaChatParser:\n def __init__(self):\n self.auth_key = os.getenv(\"GIGACHAT_AUTH_KEY\")\n self._access_token = None\n self._expires_at = 0\n\n def _get_token(self) -> Optional[str]:\n if self._access_token and time.time() < self._expires_at:\n return self._access_token\n\n logger.info(\"Obtaining GigaChat access token...\")\n headers = {\n 'Content-Type': 'application/x-www-form-urlencoded',\n 'Accept': 'application/json',\n 'RqUID': str(uuid.uuid4()),\n 'Authorization': f'Basic {self.auth_key}'\n }\n payload = {'scope': 'GIGACHAT_API_PERS'}\n\n try:\n # verify=False может понадобиться, если сертификаты Минцифры не в системном хранилище, \n # но вы указали, что установите их в контейнер.\n response = requests.post(GIGACHAT_OAUTH_URL, headers=headers, data=payload, timeout=10)\n response.raise_for_status()\n data = response.json()\n self._access_token = data['access_token']\n self._expires_at = data['expires_at'] / 1000 # Переводим мс в сек\n return self._access_token\n except Exception as e:\n logger.error(f\"GigaChat Auth error: {e}\")\n return None\n\n def parse(self, raw_text: str) -> List[ParsedItem]:\n token = self._get_token()\n if not token:\n return []\n\n headers = {\n 'Content-Type': 'application/json',\n 'Accept': 'application/json',\n 'Authorization': f'Bearer {token}'\n }\n\n payload = {\n \"model\": \"GigaChat\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": (\n \"Ты — эксперт по распознаванию чеков. Извлеки товары из текста. \"\n \"Верни ТОЛЬКО JSON массив объектов с полями: raw_name (строка), \"\n \"amount (число), price (число), sum (число). \"\n \"Если данных нет, верни []. Никаких пояснений.\"\n )\n },\n {\"role\": \"user\", \"content\": raw_text}\n ],\n \"temperature\": 0.1\n }\n\n try:\n response = requests.post(GIGACHAT_COMPLETION_URL, headers=headers, json=payload, timeout=30)\n response.raise_for_status()\n content = response.json()['choices'][0]['message']['content']\n clean_json = content.replace(\"```json\", \"\").replace(\"```\", \"\").strip()\n return [ParsedItem(**item) for item in json.loads(clean_json)]\n except Exception as e:\n logger.error(f\"GigaChat Parsing error: {e}\")\n return []\n\nclass LLMManager:\n def __init__(self):\n self.yandex = YandexGPTParser()\n self.giga = GigaChatParser()\n self.engine = os.getenv(\"LLM_ENGINE\", \"yandex\").lower()\n\n def parse_with_priority(self, raw_text: str, yandex_iam_token: Optional[str] = None) -> List[ParsedItem]:\n if self.engine == \"gigachat\":\n logger.info(\"Using GigaChat as primary LLM\")\n items = self.giga.parse(raw_text)\n if not items and yandex_iam_token:\n logger.info(\"GigaChat failed, falling back to YandexGPT\")\n items = self.yandex.parse(raw_text, yandex_iam_token)\n return items\n else:\n logger.info(\"Using YandexGPT as primary LLM\")\n items = self.yandex.parse(raw_text, yandex_iam_token) if yandex_iam_token else []\n if not items:\n logger.info(\"YandexGPT failed, falling back to GigaChat\")\n items = self.giga.parse(raw_text)\n return items\n\nllm_parser = LLMManager()", - "main.py": "import logging\nimport os\nfrom typing import List\n\nfrom fastapi import FastAPI, File, UploadFile, HTTPException\nfrom pydantic import BaseModel\nimport cv2\nimport numpy as np\n\n# Импортируем модули\nfrom imgproc import preprocess_image\nfrom parser import parse_receipt_text, ParsedItem, extract_fiscal_data\nfrom ocr import ocr_engine\nfrom qr_manager import detect_and_decode_qr, fetch_data_from_api\n# Импортируем новый модуль\nfrom yandex_ocr import yandex_engine\nfrom llm_parser import llm_parser\n\nlogging.basicConfig(\n level=logging.INFO,\n format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'\n)\nlogger = logging.getLogger(__name__)\n\napp = FastAPI(title=\"RMSER OCR Service (Hybrid: QR + Yandex + Tesseract)\")\n\nclass RecognitionResult(BaseModel):\n source: str # 'qr_api', 'yandex_vision', 'tesseract_ocr'\n items: List[ParsedItem]\n raw_text: str = \"\"\n\n@app.get(\"/health\")\ndef health_check():\n return {\"status\": \"ok\"}\n\n@app.post(\"/recognize\", response_model=RecognitionResult)\nasync def recognize_receipt(image: UploadFile = File(...)):\n \"\"\"\n Стратегия:\n 1. QR Code + FNS API (Приоритет 1 - Идеальная точность)\n 2. Yandex Vision OCR (Приоритет 2 - Высокая точность, если настроен)\n 3. Tesseract OCR (Приоритет 3 - Локальный фолбэк)\n \"\"\"\n logger.info(f\"Received file: {image.filename}, content_type: {image.content_type}\")\n\n if not image.content_type.startswith(\"image/\"):\n raise HTTPException(status_code=400, detail=\"File must be an image\")\n\n try:\n # Читаем сырые байты\n content = await image.read()\n \n # Конвертируем в numpy для QR и локального препроцессинга\n nparr = np.frombuffer(content, np.uint8)\n original_cv_image = cv2.imdecode(nparr, cv2.IMREAD_COLOR)\n\n if original_cv_image is None:\n raise HTTPException(status_code=400, detail=\"Invalid image data\")\n\n # --- ЭТАП 1: QR Code Strategy ---\n logger.info(\"--- Stage 1: QR Code Detection ---\")\n qr_raw = detect_and_decode_qr(original_cv_image)\n \n if qr_raw:\n logger.info(\"QR found! Fetching data from API...\")\n api_items = fetch_data_from_api(qr_raw)\n \n if api_items:\n logger.info(f\"Success: Retrieved {len(api_items)} items via QR API.\")\n return RecognitionResult(\n source=\"qr_api\",\n items=api_items,\n raw_text=f\"QR Content: {qr_raw}\"\n )\n else:\n logger.warning(\"QR found but API failed. Falling back to OCR.\")\n else:\n logger.info(\"QR code not found. Proceeding to OCR.\")\n\n # --- ЭТАП 2: OCR + Virtual QR Strategy ---\n if yandex_engine.oauth_token and yandex_engine.folder_id:\n logger.info(\"--- Stage 2: Yandex Vision OCR + Virtual QR ---\")\n yandex_text = yandex_engine.recognize(content)\n \n if yandex_text and len(yandex_text) > 10:\n logger.info(f\"OCR success. Raw text length: {len(yandex_text)}\")\n \n # Попытка собрать виртуальный QR из текста\n virtual_qr = extract_fiscal_data(yandex_text)\n if virtual_qr:\n logger.info(f\"Virtual QR constructed: {virtual_qr}\")\n api_items = fetch_data_from_api(virtual_qr)\n if api_items:\n logger.info(f\"Success: Retrieved {len(api_items)} items via Virtual QR API.\")\n return RecognitionResult(\n source=\"virtual_qr_api\",\n items=api_items,\n raw_text=yandex_text\n )\n \n # Если виртуальный QR не сработал, пробуем Regex\n yandex_items = parse_receipt_text(yandex_text)\n \n # Если Regex пуст — вызываем LLM (GigaChat / YandexGPT)\n if not yandex_items:\n logger.info(\"Regex found nothing. Calling LLM Manager...\")\n iam_token = yandex_engine._get_iam_token()\n yandex_items = llm_parser.parse_with_priority(yandex_text, iam_token)\n \n return RecognitionResult(\n source=\"yandex_vision_llm\",\n items=yandex_items,\n raw_text=yandex_text\n )\n else:\n logger.warning(\"Yandex Vision returned empty text or failed. Falling back to Tesseract.\")\n else:\n logger.info(\"Yandex Vision credentials not set. Skipping Stage 2.\")\n\n # --- ЭТАП 3: Tesseract Strategy (Local Fallback) ---\n logger.info(\"--- Stage 3: Tesseract OCR (Local) ---\")\n \n # 1. Image Processing (бинаризация, выравнивание)\n processed_img = preprocess_image(content)\n \n # 2. OCR\n tesseract_text = ocr_engine.recognize(processed_img)\n \n # 3. Parsing\n ocr_items = parse_receipt_text(tesseract_text)\n \n return RecognitionResult(\n source=\"tesseract_ocr\",\n items=ocr_items,\n raw_text=tesseract_text\n )\n\n except Exception as e:\n logger.error(f\"Error processing request: {e}\", exc_info=True)\n raise HTTPException(status_code=500, detail=str(e))\n\nif __name__ == \"__main__\":\n import uvicorn\n uvicorn.run(app, host=\"0.0.0.0\", port=5000)", - "ocr.py": "import logging\nimport pytesseract\nfrom PIL import Image\nimport numpy as np\n\nlogger = logging.getLogger(__name__)\n\n# Если tesseract не в PATH, раскомментируй и укажи путь:\n# pytesseract.pytesseract.tesseract_cmd = r'/usr/bin/tesseract'\n\nclass OCREngine:\n def __init__(self):\n logger.info(\"Initializing Tesseract OCR wrapper...\")\n # Tesseract не требует загрузки моделей в память, \n # проверка версии просто чтобы убедиться, что он установлен\n try:\n version = pytesseract.get_tesseract_version()\n logger.info(f\"Tesseract version found: {version}\")\n except Exception as e:\n logger.error(\"Tesseract not found! Make sure it is installed (apt install tesseract-ocr).\")\n raise e\n\n def recognize(self, image: np.ndarray) -> str:\n \"\"\"\n Принимает бинарное изображение (numpy array).\n \"\"\"\n # Tesseract работает лучше с PIL Image\n pil_img = Image.fromarray(image)\n \n # Конфигурация:\n # -l rus+eng: русский и английский\n # --psm 6: Assume a single uniform block of text (хорошо для чеков)\n custom_config = r'--oem 3 --psm 6'\n \n text = pytesseract.image_to_string(pil_img, lang='rus+eng', config=custom_config)\n return text\n\nocr_engine = OCREngine()", - "parser.py": "import re\nfrom typing import List, Optional\nfrom pydantic import BaseModel\nfrom datetime import datetime\n\nclass ParsedItem(BaseModel):\n raw_name: str\n amount: float\n price: float\n sum: float\n\n# Регулярка для поиска чисел с плавающей точкой: 123.00, 123,00, 10.5\nFLOAT_RE = r'\\d+[.,]\\d{2}'\n\ndef clean_text(text: str) -> str:\n \"\"\"Удаляет лишние символы из названия товара.\"\"\"\n return re.sub(r'[^\\w\\s.,%/-]', '', text).strip()\n\ndef parse_float(val: str) -> float:\n \"\"\"Преобразует строку '123,45' или '123.45' в float.\"\"\"\n if not val:\n return 0.0\n return float(val.replace(',', '.').replace(' ', ''))\n\ndef extract_fiscal_data(text: str) -> Optional[str]:\n \"\"\"\n Ищет в тексте:\n - Дата и время (t): 19.12.25 12:16 -> 20251219T1216\n - Сумма (s): ИТОГ: 770.00\n - ФН (fn): 16 цифр, начинается на 73\n - ФД (i): до 8 цифр (ищем после Д: или ФД:)\n - ФП (fp): 10 цифр (ищем после П: или ФП:)\n \n Возвращает строку формата: t=20251219T1216&s=770.00&fn=7384440800514469&i=11194&fp=3334166168&n=1\n \"\"\"\n # 1. Поиск даты и времени\n # Ищем форматы DD.MM.YY HH:MM или DD.MM.YYYY HH:MM\n date_match = re.search(r'(\\d{2})\\.(\\d{2})\\.(\\d{2,4})\\s+(\\d{2}):(\\d{2})', text)\n t_param = \"\"\n if date_match:\n d, m, y, hh, mm = date_match.groups()\n if len(y) == 2: y = \"20\" + y\n t_param = f\"{y}{m}{d}T{hh}{mm}\"\n\n # 2. Поиск суммы (Итог)\n # Ищем слово ИТОГ и число после него\n sum_match = re.search(r'(?:ИТОГ|СУММА|СУММА:)\\s*[:]*\\s*(\\d+[.,]\\d{2})', text, re.IGNORECASE)\n s_param = \"\"\n if sum_match:\n s_param = sum_match.group(1).replace(',', '.')\n\n # 3. Поиск ФН (16 цифр, начинается с 73)\n fn_match = re.search(r'\\b(73\\d{14})\\b', text)\n fn_param = fn_match.group(1) if fn_match else \"\"\n\n # 4. Поиск ФД (i) - ищем после маркеров Д: или ФД:\n # Берем набор цифр до 8 знаков\n fd_match = re.search(r'(?:ФД|Д)[:\\s]+(\\d{1,8})\\b', text)\n i_param = fd_match.group(1) if fd_match else \"\"\n\n # 5. Поиск ФП (fp) - ищем после маркеров П: или ФП:\n # Строго 10 цифр\n fp_match = re.search(r'(?:ФП|П)[:\\s]+(\\d{10})\\b', text)\n fp_param = fp_match.group(1) if fp_match else \"\"\n\n # Валидация: для формирования запроса к API нам критически важны все параметры\n if all([t_param, s_param, fn_param, i_param, fp_param]):\n return f\"t={t_param}&s={s_param}&fn={fn_param}&i={i_param}&fp={fp_param}&n=1\"\n \n return None\n\ndef parse_receipt_text(text: str) -> List[ParsedItem]:\n \"\"\"\n Парсит текст чека построчно (Regex-метод).\n \"\"\"\n lines = text.split('\\n')\n items = []\n name_buffer = []\n\n for line in lines:\n line = line.strip()\n if not line:\n continue\n\n floats = re.findall(FLOAT_RE, line)\n is_price_line = False\n \n if len(floats) >= 2:\n is_price_line = True\n vals = [parse_float(f) for f in floats]\n \n price = 0.0\n amount = 1.0\n total = vals[-1]\n \n if len(vals) == 2:\n price = vals[0]\n amount = 1.0\n if total > price and price > 0:\n calc_amount = total / price\n if abs(round(calc_amount) - calc_amount) < 0.05:\n amount = float(round(calc_amount))\n elif len(vals) >= 3:\n v1, v2 = vals[-3], vals[-2]\n if abs(v1 * v2 - total) < 0.5:\n price, amount = v1, v2\n elif abs(v2 * v1 - total) < 0.5:\n price, amount = v2, v1\n else:\n price, amount = vals[-2], 1.0\n\n full_name = \" \".join(name_buffer).strip()\n if not full_name:\n text_without_floats = re.sub(FLOAT_RE, '', line)\n full_name = clean_text(text_without_floats)\n\n if len(full_name) > 2 and total > 0:\n items.append(ParsedItem(\n raw_name=full_name,\n amount=amount,\n price=price,\n sum=total\n ))\n name_buffer = []\n else:\n upper_line = line.upper()\n if any(stop in upper_line for stop in [\"ИТОГ\", \"СУММА\", \"ПРИХОД\"]):\n name_buffer = [] \n continue\n name_buffer.append(line)\n\n return items", - "qr_manager.py": "import logging\nimport requests\nfrom typing import Optional, List\nfrom pyzbar.pyzbar import decode\nfrom PIL import Image\nimport numpy as np\n\n# Импортируем модель из parser.py\nfrom parser import ParsedItem \n\nAPI_TOKEN = \"36590.yqtiephCvvkYUKM2W\"\nAPI_URL = \"https://proverkacheka.com/api/v1/check/get\"\n\nlogger = logging.getLogger(__name__)\n\ndef is_valid_fiscal_qr(qr_string: str) -> bool:\n \"\"\"\n Проверяет, соответствует ли строка формату фискального чека ФНС.\n Ожидаемый формат: t=...&s=...&fn=...&i=...&fp=...&n=...\n Мы проверяем наличие хотя бы 3-х ключевых параметров.\n \"\"\"\n if not qr_string:\n return False\n \n # Ключевые параметры, которые обязаны быть в строке чека\n required_keys = [\"t=\", \"s=\", \"fn=\"]\n \n # Проверяем, что все ключевые параметры присутствуют\n # (порядок может отличаться, поэтому проверяем вхождение каждого)\n matches = [key in qr_string for key in required_keys]\n \n return all(matches)\n\ndef detect_and_decode_qr(image: np.ndarray) -> Optional[str]:\n \"\"\"\n Ищет ВСЕ QR-коды на изображении и возвращает только тот, \n который похож на фискальный чек.\n \"\"\"\n try:\n pil_img = Image.fromarray(image)\n \n # Декодируем все коды на картинке\n decoded_objects = decode(pil_img)\n \n if not decoded_objects:\n logger.info(\"No QR codes detected on the image.\")\n return None\n \n logger.info(f\"Detected {len(decoded_objects)} code(s). Scanning for fiscal data...\")\n\n for obj in decoded_objects:\n if obj.type == 'QRCODE':\n qr_data = obj.data.decode(\"utf-8\")\n \n # Логируем найденное (для отладки, если вдруг формат хитрый)\n # Обрезаем длинные строки, чтобы не засорять лог\n log_preview = (qr_data[:75] + '..') if len(qr_data) > 75 else qr_data\n logger.info(f\"Checking QR content: {log_preview}\")\n \n if is_valid_fiscal_qr(qr_data):\n logger.info(\"Valid fiscal QR found!\")\n return qr_data\n else:\n logger.info(\"QR skipped (not a fiscal receipt pattern).\")\n \n logger.warning(\"QR codes were found, but none matched the fiscal receipt format.\")\n return None\n\n except Exception as e:\n logger.error(f\"Error during QR detection: {e}\")\n return None\n\ndef fetch_data_from_api(qr_raw: str) -> List[ParsedItem]:\n \"\"\"\n Отправляет данные QR-кода в API proverkacheka.com и парсит JSON-ответ.\n \"\"\"\n try:\n payload = {\n 'qrraw': qr_raw,\n 'token': API_TOKEN\n }\n \n logger.info(\"Sending request to Check API...\")\n response = requests.post(API_URL, data=payload, timeout=10)\n \n if response.status_code != 200:\n logger.error(f\"API Error: Status {response.status_code}\")\n return []\n\n data = response.json()\n \n if data.get('code') != 1:\n logger.warning(f\"API returned non-success code: {data.get('code')}\")\n return []\n \n json_data = data.get('data', {}).get('json', {})\n items_data = json_data.get('items', [])\n \n parsed_items = []\n \n for item in items_data:\n price = float(item.get('price', 0)) / 100.0\n total_sum = float(item.get('sum', 0)) / 100.0\n quantity = float(item.get('quantity', 0))\n name = item.get('name', 'Unknown')\n \n parsed_items.append(ParsedItem(\n raw_name=name,\n amount=quantity,\n price=price,\n sum=total_sum\n ))\n \n return parsed_items\n\n except Exception as e:\n logger.error(f\"Error fetching/parsing API data: {e}\")\n return []", - "requirements.txt": "fastapi\nuvicorn\npython-multipart\npydantic\nnumpy\nopencv-python-headless\npytesseract\nrequests\npyzbar\npillow\ncertifi", - "yandex_ocr.py": "import os\nimport time\nimport json\nimport base64\nimport logging\nimport requests\nfrom typing import Optional\n\nlogger = logging.getLogger(__name__)\n\nIAM_TOKEN_URL = \"https://iam.api.cloud.yandex.net/iam/v1/tokens\"\nVISION_URL = \"https://ocr.api.cloud.yandex.net/ocr/v1/recognizeText\"\n\nclass YandexOCREngine:\n def __init__(self):\n self.oauth_token = os.getenv(\"YANDEX_OAUTH_TOKEN\")\n self.folder_id = os.getenv(\"YANDEX_FOLDER_ID\")\n \n # Кэширование IAM токена\n self._iam_token = None\n self._token_expire_time = 0\n\n if not self.oauth_token or not self.folder_id:\n logger.warning(\"Yandex OCR credentials (YANDEX_OAUTH_TOKEN, YANDEX_FOLDER_ID) not set. Yandex OCR will be unavailable.\")\n\n def _get_iam_token(self) -> Optional[str]:\n \"\"\"\n Получает IAM-токен. Если есть живой кэшированный — возвращает его.\n Если нет — обменивает OAuth на IAM.\n \"\"\"\n current_time = time.time()\n \n # Если токен есть и он \"свежий\" (с запасом в 5 минут)\n if self._iam_token and current_time < self._token_expire_time - 300:\n return self._iam_token\n\n logger.info(\"Obtaining new IAM token from Yandex...\")\n try:\n response = requests.post(\n IAM_TOKEN_URL,\n json={\"yandexPassportOauthToken\": self.oauth_token},\n timeout=10\n )\n response.raise_for_status()\n data = response.json()\n \n self._iam_token = data[\"iamToken\"]\n \n # Токен обычно живет 12 часов, но мы будем ориентироваться на поле expiresAt если нужно,\n # или просто поставим таймер. Для простоты берем 1 час жизни кэша.\n self._token_expire_time = current_time + 3600 \n \n logger.info(\"IAM token received successfully.\")\n return self._iam_token\n except Exception as e:\n logger.error(f\"Failed to get IAM token: {e}\")\n return None\n\n def recognize(self, image_bytes: bytes) -> str:\n \"\"\"\n Отправляет изображение в Yandex Vision и возвращает полный текст.\n \"\"\"\n if not self.oauth_token or not self.folder_id:\n logger.error(\"Yandex credentials missing.\")\n return \"\"\n\n iam_token = self._get_iam_token()\n if not iam_token:\n return \"\"\n\n # 1. Кодируем в Base64\n b64_image = base64.b64encode(image_bytes).decode(\"utf-8\")\n\n # 2. Формируем тело запроса\n # Используем модель 'page' (для документов) и '*' для автоопределения языка\n payload = {\n \"mimeType\": \"JPEG\", # Yandex переваривает и PNG под видом JPEG часто, но лучше быть аккуратным.\n # В идеале определять mime-type из файла, но JPEG - безопасный дефолт для фото.\n \"languageCodes\": [\"*\"],\n \"model\": \"page\",\n \"content\": b64_image\n }\n\n headers = {\n \"Content-Type\": \"application/json\",\n \"Authorization\": f\"Bearer {iam_token}\",\n \"x-folder-id\": self.folder_id,\n \"x-data-logging-enabled\": \"true\"\n }\n\n # 3. Отправляем запрос\n try:\n logger.info(\"Sending request to Yandex Vision OCR...\")\n response = requests.post(VISION_URL, headers=headers, json=payload, timeout=20)\n \n # Если 401 Unauthorized, возможно токен протух раньше времени (редко, но бывает)\n if response.status_code == 401:\n logger.warning(\"Got 401 from Yandex. Retrying with fresh token...\")\n self._iam_token = None # сброс кэша\n iam_token = self._get_iam_token()\n if iam_token:\n headers[\"Authorization\"] = f\"Bearer {iam_token}\"\n response = requests.post(VISION_URL, headers=headers, json=payload, timeout=20)\n \n response.raise_for_status()\n result_json = response.json()\n \n # 4. Парсим ответ\n # Структура: result -> textAnnotation -> fullText\n # Или (если fullText нет) blocks -> lines -> text\n \n text_annotation = result_json.get(\"result\", {}).get(\"textAnnotation\", {})\n \n if not text_annotation:\n logger.warning(\"Yandex returned success but no textAnnotation found.\")\n return \"\"\n \n # Самый простой способ - взять fullText, он обычно склеен с \\n\n full_text = text_annotation.get(\"fullText\", \"\")\n \n if not full_text:\n # Фолбэк: если fullText пуст, собираем вручную по блокам\n logger.info(\"fullText empty, assembling from blocks...\")\n lines_text = []\n for block in text_annotation.get(\"blocks\", []):\n for line in block.get(\"lines\", []):\n lines_text.append(line.get(\"text\", \"\"))\n full_text = \"\\n\".join(lines_text)\n\n return full_text\n\n except Exception as e:\n logger.error(f\"Error during Yandex Vision request: {e}\")\n return \"\"\n\n# Глобальный инстанс\nyandex_engine = YandexOCREngine()", + "app/main.py": "import logging\nimport os\nfrom typing import List\n\nfrom fastapi import FastAPI, File, UploadFile, HTTPException\nfrom pydantic import BaseModel\nimport cv2\nimport numpy as np\n\n# Импортируем модули\nfrom app.schemas.models import ParsedItem, RecognitionResult\nfrom app.services.qr import detect_and_decode_qr, fetch_data_from_api, extract_fiscal_data\n# Импортируем новый модуль\nfrom app.services.ocr import yandex_engine\nfrom app.services.llm import llm_parser\n\nlogging.basicConfig(\n level=logging.INFO,\n format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'\n)\nlogger = logging.getLogger(__name__)\n\napp = FastAPI(title=\"RMSER OCR Service (Cloud-only: QR + Yandex + GigaChat)\")\n\n@app.get(\"/health\")\ndef health_check():\n return {\"status\": \"ok\"}\n\n@app.post(\"/recognize\", response_model=RecognitionResult)\nasync def recognize_receipt(image: UploadFile = File(...)):\n \"\"\"\n Стратегия:\n 1. QR Code + FNS API (Приоритет 1 - Идеальная точность)\n 2. Yandex Vision OCR + LLM (Приоритет 2 - Высокая точность, если настроен)\n Если ничего не найдено, возвращает пустой результат.\n \"\"\"\n logger.info(f\"Received file: {image.filename}, content_type: {image.content_type}\")\n\n if not image.content_type.startswith(\"image/\"):\n raise HTTPException(status_code=400, detail=\"File must be an image\")\n\n try:\n # Читаем сырые байты\n content = await image.read()\n \n # Конвертируем в numpy для QR\n nparr = np.frombuffer(content, np.uint8)\n original_cv_image = cv2.imdecode(nparr, cv2.IMREAD_COLOR)\n\n if original_cv_image is None:\n raise HTTPException(status_code=400, detail=\"Invalid image data\")\n\n # --- ЭТАП 1: QR Code Strategy ---\n logger.info(\"--- Stage 1: QR Code Detection ---\")\n qr_raw = detect_and_decode_qr(original_cv_image)\n \n if qr_raw:\n logger.info(\"QR found! Fetching data from API...\")\n api_items = fetch_data_from_api(qr_raw)\n \n if api_items:\n logger.info(f\"Success: Retrieved {len(api_items)} items via QR API.\")\n return RecognitionResult(\n source=\"qr_api\",\n items=api_items,\n raw_text=f\"QR Content: {qr_raw}\"\n )\n else:\n logger.warning(\"QR found but API failed. Falling back to OCR.\")\n else:\n logger.info(\"QR code not found. Proceeding to OCR.\")\n\n # --- ЭТАП 2: OCR + Virtual QR Strategy ---\n if yandex_engine.is_configured():\n logger.info(\"--- Stage 2: Yandex Vision OCR + Virtual QR ---\")\n yandex_text = yandex_engine.recognize(content)\n\n if yandex_text and len(yandex_text) > 10:\n logger.info(f\"OCR success. Raw text length: {len(yandex_text)}\")\n\n # Попытка собрать виртуальный QR из текста\n virtual_qr = extract_fiscal_data(yandex_text)\n if virtual_qr:\n logger.info(f\"Virtual QR constructed: {virtual_qr}\")\n api_items = fetch_data_from_api(virtual_qr)\n if api_items:\n logger.info(f\"Success: Retrieved {len(api_items)} items via Virtual QR API.\")\n return RecognitionResult(\n source=\"virtual_qr_api\",\n items=api_items,\n raw_text=yandex_text\n )\n\n # Вызываем LLM для парсинга текста\n logger.info(\"Calling LLM Manager to parse text...\")\n yandex_items = llm_parser.parse_receipt(yandex_text)\n\n return RecognitionResult(\n source=\"yandex_vision_llm\",\n items=yandex_items,\n raw_text=yandex_text\n )\n else:\n logger.warning(\"Yandex Vision returned empty text or failed. No fallback available.\")\n return RecognitionResult(\n source=\"none\",\n items=[],\n raw_text=\"\"\n )\n else:\n logger.info(\"Yandex Vision credentials not set. No OCR available.\")\n return RecognitionResult(\n source=\"none\",\n items=[],\n raw_text=\"\"\n )\n\n except Exception as e:\n logger.error(f\"Error processing request: {e}\", exc_info=True)\n raise HTTPException(status_code=500, detail=str(e))\n\nif __name__ == \"__main__\":\n import uvicorn\n uvicorn.run(app, host=\"0.0.0.0\", port=5000)", + "app/schemas/models.py": "from typing import List\nfrom pydantic import BaseModel\n\nclass ParsedItem(BaseModel):\n raw_name: str\n amount: float\n price: float\n sum: float\n\nclass RecognitionResult(BaseModel):\n source: str # 'qr_api', 'virtual_qr_api', 'yandex_vision_llm', 'none'\n items: List[ParsedItem]\n raw_text: str = \"\"", + "app/services/auth.py": "import os\nimport time\nimport logging\nimport requests\nfrom typing import Optional\n\nlogger = logging.getLogger(__name__)\n\nIAM_TOKEN_URL = \"https://iam.api.cloud.yandex.net/iam/v1/tokens\"\n\nclass YandexAuthManager:\n def __init__(self):\n self.oauth_token = os.getenv(\"YANDEX_OAUTH_TOKEN\")\n \n # Кэширование IAM токена\n self._iam_token = None\n self._token_expire_time = 0\n\n if not self.oauth_token:\n logger.warning(\"YANDEX_OAUTH_TOKEN not set. Yandex services will be unavailable.\")\n\n def is_configured(self) -> bool:\n return bool(self.oauth_token)\n\n def reset_token(self):\n \"\"\"Сбрасывает кэшированный токен, заставляя получить новый при следующем вызове.\"\"\"\n self._iam_token = None\n self._token_expire_time = 0\n\n def get_iam_token(self) -> Optional[str]:\n \"\"\"\n Получает IAM-токен. Если есть живой кэшированный — возвращает его.\n Если нет — обменивает OAuth на IAM.\n \"\"\"\n current_time = time.time()\n \n # Если токен есть и он \"свежий\" (с запасом в 5 минут)\n if self._iam_token and current_time < self._token_expire_time - 300:\n return self._iam_token\n\n if not self.oauth_token:\n logger.error(\"OAuth token not available.\")\n return None\n\n logger.info(\"Obtaining new IAM token from Yandex...\")\n try:\n response = requests.post(\n IAM_TOKEN_URL,\n json={\"yandexPassportOauthToken\": self.oauth_token},\n timeout=10\n )\n response.raise_for_status()\n data = response.json()\n \n self._iam_token = data[\"iamToken\"]\n \n # Токен обычно живет 12 часов, но мы будем ориентироваться на поле expiresAt если нужно,\n # или просто поставим таймер. Для простоты берем 1 час жизни кэша.\n self._token_expire_time = current_time + 3600 \n \n logger.info(\"IAM token received successfully.\")\n return self._iam_token\n except Exception as e:\n logger.error(f\"Failed to get IAM token: {e}\")\n return None\n\n# Глобальный инстанс\nyandex_auth = YandexAuthManager()", + "app/services/llm.py": "import os\nimport requests\nfrom requests.exceptions import SSLError\nimport logging\nimport json\nimport uuid\nimport time\nimport re\nfrom abc import ABC, abstractmethod\nfrom typing import List, Optional\n\nfrom app.schemas.models import ParsedItem\nfrom app.services.auth import yandex_auth\n\nlogger = logging.getLogger(__name__)\n\nYANDEX_GPT_URL = \"https://llm.api.cloud.yandex.net/foundationModels/v1/completion\"\nGIGACHAT_OAUTH_URL = \"https://ngw.devices.sberbank.ru:9443/api/v2/oauth\"\nGIGACHAT_COMPLETION_URL = \"https://gigachat.devices.sberbank.ru/api/v1/chat/completions\"\n\nclass LLMProvider(ABC):\n @abstractmethod\n def generate(self, system_prompt: str, user_text: str) -> str:\n pass\n\nclass YandexGPTProvider(LLMProvider):\n def __init__(self):\n self.folder_id = os.getenv(\"YANDEX_FOLDER_ID\")\n\n def generate(self, system_prompt: str, user_text: str) -> str:\n iam_token = yandex_auth.get_iam_token()\n if not iam_token:\n raise Exception(\"Failed to get IAM token\")\n\n prompt = {\n \"modelUri\": f\"gpt://{self.folder_id}/yandexgpt/latest\",\n \"completionOptions\": {\"stream\": False, \"temperature\": 0.1, \"maxTokens\": \"2000\"},\n \"messages\": [\n {\"role\": \"system\", \"text\": system_prompt},\n {\"role\": \"user\", \"text\": user_text}\n ]\n }\n\n headers = {\n \"Content-Type\": \"application/json\",\n \"Authorization\": f\"Bearer {iam_token}\",\n \"x-folder-id\": self.folder_id\n }\n\n response = requests.post(YANDEX_GPT_URL, headers=headers, json=prompt, timeout=30)\n response.raise_for_status()\n content = response.json()['result']['alternatives'][0]['message']['text']\n return content\n\nclass GigaChatProvider(LLMProvider):\n def __init__(self):\n self.auth_key = os.getenv(\"GIGACHAT_AUTH_KEY\")\n self._access_token = None\n self._expires_at = 0\n\n def _get_token(self) -> Optional[str]:\n if self._access_token and time.time() < self._expires_at:\n return self._access_token\n\n logger.info(\"Obtaining GigaChat access token...\")\n headers = {\n 'Content-Type': 'application/x-www-form-urlencoded',\n 'Accept': 'application/json',\n 'RqUID': str(uuid.uuid4()),\n 'Authorization': f'Basic {self.auth_key}'\n }\n payload = {'scope': 'GIGACHAT_API_PERS'}\n\n response = requests.post(GIGACHAT_OAUTH_URL, headers=headers, data=payload, timeout=10)\n response.raise_for_status()\n data = response.json()\n self._access_token = data['access_token']\n self._expires_at = data['expires_at'] / 1000 # Переводим мс в сек\n return self._access_token\n\n def generate(self, system_prompt: str, user_text: str) -> str:\n token = self._get_token()\n if not token:\n raise Exception(\"Failed to get GigaChat token\")\n\n headers = {\n 'Content-Type': 'application/json',\n 'Accept': 'application/json',\n 'Authorization': f'Bearer {token}'\n }\n\n payload = {\n \"model\": \"GigaChat\",\n \"messages\": [\n {\"role\": \"system\", \"content\": system_prompt},\n {\"role\": \"user\", \"content\": user_text}\n ],\n \"temperature\": 0.1\n }\n\n try:\n response = requests.post(GIGACHAT_COMPLETION_URL, headers=headers, json=payload, timeout=30)\n response.raise_for_status()\n content = response.json()['choices'][0]['message']['content']\n return content\n except SSLError as e:\n logger.error(\"SSL Error with GigaChat. Check certificates\")\n raise e\n\nclass LLMManager:\n def __init__(self):\n engine = os.getenv(\"LLM_ENGINE\", \"yandex\").lower()\n self.engine = engine\n if engine == \"gigachat\":\n self.provider = GigaChatProvider()\n else:\n self.provider = YandexGPTProvider()\n logger.info(f\"LLM Engine initialized: {self.engine}\")\n\n def parse_receipt(self, raw_text: str) -> List[ParsedItem]:\n system_prompt = \"\"\"\nТы — профессиональный бухгалтер. Твоя задача — извлечь из \"сырого\" текста (OCR) структурированные данные о товарах.\n\nВХОДНЫЕ ДАННЫЕ:\nТекст чека или накладной. Может содержать мусор, заголовки, итоги, служебную информацию.\n\nФОРМАТ ОТВЕТА:\nТолько валидный JSON массив.\n[\n {\n \"raw_name\": \"Название товара\",\n \"amount\": 1.0, // Количество\n \"price\": 100.0, // Цена за единицу (с учетом скидок)\n \"sum\": 100.0 // Стоимость позиции\n }\n]\n\nПРАВИЛА:\n1. Игнорируй строки: \"ИТОГ\", \"СУММА\", \"НДС\", \"Кассир\", \"Сдача\", \"Безналичными\", телефоны, адреса, ссылки.\n2. Очищай числа от пробелов (например, \"1 630,00\" -> 1630.0).\n3. Если количество (amount) явно не указано, считай его равным 1.\n4. **КРИТИЧНО:** Один товар ЧАСТО занимает НЕСКОЛЬКО СТРОК. Название товара в первой строке, цифры (цена, количество, сумма) в последующих. **НИКОГДА НЕ ОБЪЕДИНЯЙ РАЗНЫЕ ТОВАРЫ В ОДНУ ЗАПИСЬ.** Анализируй блоки строк внимательно.\n **КРИТИЧНО:** Если в тексте есть строки вида `66.99 66.99 3шт. 200.97`, это значит: Цена=66.99, Кол-во=3, Сумма=200.97. Название товара находится СТРОГО В ПРЕДЫДУЩЕЙ СТРОКЕ.\n5. Название товара (raw_name) должно быть полным, но без цены и количества.\n\nВАЖНОЕ ПРАВИЛО ДЛЯ ЧЕКОВ ТИПА \"МАГНИТ\":\nЧасто информация о товаре разбита на две строки:\n1. Строка с названием: \"Яйцо столовое СО 10шт бокс:20\"\n2. Строка с цифрами: \"66.99 66.99 3шт. 200.97\"\n\nЕсли ты видишь строку с цифрами, ищи название в ПРЕДЫДУЩЕЙ строке. Объединяй их в один объект.\nНе создавай отдельный товар из строки с цифрами.\n\nПРИМЕР 1 (Простой):\nВход:\nХЛЕБ БОРОДИН 1 Х 45.00 45.00\nМОЛОКО 3.2% 2 Х 80.00 160.00\nВыход:\n[\n {\"raw_name\": \"ХЛЕБ БОРОДИН\", \"amount\": 1.0, \"price\": 45.0, \"sum\": 45.0},\n {\"raw_name\": \"МОЛОКО 3.2%\", \"amount\": 2.0, \"price\": 80.0, \"sum\": 160.0}\n]\n\nПРИМЕР 2 (Сложный, многострочный):\nВход:\nЯйцо столовое СО 10шт бокс:20\n66.99\n66.99\n3шт.\n200.97\nМАГНИТ Пакет-майка большой 15кг\n9.99\n9.99\n1шт.\n9.99\nСВЕКЛА 1кг\n32.99 32.99 1.292кг 42.62\nВыход:\n[\n {\"raw_name\": \"Яйцо столовое СО 10шт бокс:20\", \"amount\": 3.0, \"price\": 66.99, \"sum\": 200.97},\n {\"raw_name\": \"МАГНИТ Пакет-майка большой 15кг\", \"amount\": 1.0, \"price\": 9.99, \"sum\": 9.99},\n {\"raw_name\": \"СВЕКЛА 1кг\", \"amount\": 1.292, \"price\": 32.99, \"sum\": 42.62}\n]\n\"\"\"\n\n try:\n logger.info(f\"Parsing receipt using engine: {self.engine}\")\n response = self.provider.generate(system_prompt, raw_text)\n # Очистка от Markdown\n clean_json = response.replace(\"```json\", \"\").replace(\"```\", \"\").strip()\n # Парсинг JSON\n data = json.loads(clean_json)\n # Пост-обработка: исправление чисел\n for item in data:\n for key in ['amount', 'price', 'sum']:\n if isinstance(item[key], str):\n # Удаляем пробелы внутри чисел\n item[key] = float(re.sub(r'\\s+', '', item[key]).replace(',', '.'))\n elif isinstance(item[key], (int, float)):\n item[key] = float(item[key])\n return [ParsedItem(**item) for item in data]\n except Exception as e:\n logger.error(f\"LLM Parsing error: {e}\")\n return []\n\nllm_parser = LLMManager()", + "app/services/ocr.py": "from abc import ABC, abstractmethod\nfrom typing import Optional\nimport os\nimport json\nimport base64\nimport logging\nimport requests\n\nfrom app.services.auth import yandex_auth\n\nlogger = logging.getLogger(__name__)\n\nVISION_URL = \"https://ocr.api.cloud.yandex.net/ocr/v1/recognizeText\"\n\nclass OCREngine(ABC):\n @abstractmethod\n def recognize(self, image_bytes: bytes) -> str:\n \"\"\"Распознает текст из изображения и возвращает строку.\"\"\"\n pass\n\n @abstractmethod\n def is_configured(self) -> bool:\n \"\"\"Проверяет, настроен ли движок (наличие ключей/настроек).\"\"\"\n pass\n\nclass YandexOCREngine(OCREngine):\n def __init__(self):\n self.folder_id = os.getenv(\"YANDEX_FOLDER_ID\")\n\n if not yandex_auth.is_configured() or not self.folder_id:\n logger.warning(\"Yandex OCR credentials (YANDEX_OAUTH_TOKEN, YANDEX_FOLDER_ID) not set. Yandex OCR will be unavailable.\")\n\n def is_configured(self) -> bool:\n return yandex_auth.is_configured() and bool(self.folder_id)\n\n def recognize(self, image_bytes: bytes) -> str:\n \"\"\"\n Отправляет изображение в Yandex Vision и возвращает полный текст.\n \"\"\"\n if not self.is_configured():\n logger.error(\"Yandex credentials missing.\")\n return \"\"\n\n iam_token = yandex_auth.get_iam_token()\n if not iam_token:\n return \"\"\n\n # 1. Кодируем в Base64\n b64_image = base64.b64encode(image_bytes).decode(\"utf-8\")\n\n # 2. Формируем тело запроса\n # Используем модель 'page' (для документов) и '*' для автоопределения языка\n payload = {\n \"mimeType\": \"JPEG\", # Yandex переваривает и PNG под видом JPEG часто, но лучше быть аккуратным.\n # В идеале определять mime-type из файла, но JPEG - безопасный дефолт для фото.\n \"languageCodes\": [\"*\"],\n \"model\": \"page\",\n \"content\": b64_image\n }\n\n headers = {\n \"Content-Type\": \"application/json\",\n \"Authorization\": f\"Bearer {iam_token}\",\n \"x-folder-id\": self.folder_id,\n \"x-data-logging-enabled\": \"true\"\n }\n\n # 3. Отправляем запрос\n try:\n logger.info(\"Sending request to Yandex Vision OCR...\")\n response = requests.post(VISION_URL, headers=headers, json=payload, timeout=20)\n \n # Если 401 Unauthorized, возможно токен протух раньше времени (редко, но бывает)\n if response.status_code == 401:\n logger.warning(\"Got 401 from Yandex. Retrying with fresh token...\")\n yandex_auth.reset_token() # сброс кэша\n iam_token = yandex_auth.get_iam_token()\n if iam_token:\n headers[\"Authorization\"] = f\"Bearer {iam_token}\"\n response = requests.post(VISION_URL, headers=headers, json=payload, timeout=20)\n \n response.raise_for_status()\n result_json = response.json()\n \n # 4. Парсим ответ\n # Структура: result -> textAnnotation -> fullText\n # Или (если fullText нет) blocks -> lines -> text\n \n text_annotation = result_json.get(\"result\", {}).get(\"textAnnotation\", {})\n \n if not text_annotation:\n logger.warning(\"Yandex returned success but no textAnnotation found.\")\n return \"\"\n \n # Самый простой способ - взять fullText, он обычно склеен с \\n\n full_text = text_annotation.get(\"fullText\", \"\")\n \n if not full_text:\n # Фолбэк: если fullText пуст, собираем вручную по блокам\n logger.info(\"fullText empty, assembling from blocks...\")\n lines_text = []\n for block in text_annotation.get(\"blocks\", []):\n for line in block.get(\"lines\", []):\n lines_text.append(line.get(\"text\", \"\"))\n full_text = \"\\n\".join(lines_text)\n\n return full_text\n\n except Exception as e:\n logger.error(f\"Error during Yandex Vision request: {e}\")\n return \"\"\n\n# Глобальный инстанс\nyandex_engine = YandexOCREngine()", + "app/services/qr.py": "import logging\nimport requests\nimport re\nfrom typing import Optional, List\nfrom pyzbar.pyzbar import decode\nfrom PIL import Image\nimport numpy as np\n\nfrom app.schemas.models import ParsedItem\n\nAPI_TOKEN = \"36590.yqtiephCvvkYUKM2W\"\nAPI_URL = \"https://proverkacheka.com/api/v1/check/get\"\n\nlogger = logging.getLogger(__name__)\n\ndef extract_fiscal_data(text: str) -> Optional[str]:\n \"\"\"\n Ищет в тексте:\n - Дата и время (t): 19.12.25 12:16 -> 20251219T1216\n - Сумма (s): ИТОГ: 770.00\n - ФН (fn): 16 цифр, начинается на 73\n - ФД (i): до 8 цифр (ищем после Д: или ФД:)\n - ФП (fp): 10 цифр (ищем после П: или ФП:)\n \n Возвращает строку формата: t=20251219T1216&s=770.00&fn=7384440800514469&i=11194&fp=3334166168&n=1\n \"\"\"\n # 1. Поиск даты и времени\n # Ищем форматы DD.MM.YY HH:MM или DD.MM.YYYY HH:MM\n date_match = re.search(r'(\\d{2})\\.(\\d{2})\\.(\\d{2,4})\\s+(\\d{2}):(\\d{2})', text)\n t_param = \"\"\n if date_match:\n d, m, y, hh, mm = date_match.groups()\n if len(y) == 2: y = \"20\" + y\n t_param = f\"{y}{m}{d}T{hh}{mm}\"\n\n # 2. Поиск суммы (Итог)\n # Ищем слово ИТОГ и число после него\n sum_match = re.search(r'(?:ИТОГ|СУММА|СУММА:)\\s*[:]*\\s*(\\d+[.,]\\d{2})', text, re.IGNORECASE)\n s_param = \"\"\n if sum_match:\n s_param = sum_match.group(1).replace(',', '.')\n\n # 3. Поиск ФН (16 цифр, начинается с 73)\n fn_match = re.search(r'\\b(73\\d{14})\\b', text)\n fn_param = fn_match.group(1) if fn_match else \"\"\n\n # 4. Поиск ФД (i) - ищем после маркеров Д: или ФД:\n # Берем набор цифр до 8 знаков\n fd_match = re.search(r'(?:ФД|Д)[:\\s]+(\\d{1,8})\\b', text)\n i_param = fd_match.group(1) if fd_match else \"\"\n\n # 5. Поиск ФП (fp) - ищем после маркеров П: или ФП:\n # Строго 10 цифр\n fp_match = re.search(r'(?:ФП|П)[:\\s]+(\\d{10})\\b', text)\n fp_param = fp_match.group(1) if fp_match else \"\"\n\n # Валидация: для формирования запроса к API нам критически важны все параметры\n if all([t_param, s_param, fn_param, i_param, fp_param]):\n return f\"t={t_param}&s={s_param}&fn={fn_param}&i={i_param}&fp={fp_param}&n=1\"\n \n return None\n\ndef is_valid_fiscal_qr(qr_string: str) -> bool:\n \"\"\"\n Проверяет, соответствует ли строка формату фискального чека ФНС.\n Ожидаемый формат: t=...&s=...&fn=...&i=...&fp=...&n=...\n Мы проверяем наличие хотя бы 3-х ключевых параметров.\n \"\"\"\n if not qr_string:\n return False\n \n # Ключевые параметры, которые обязаны быть в строке чека\n required_keys = [\"t=\", \"s=\", \"fn=\"]\n \n # Проверяем, что все ключевые параметры присутствуют\n # (порядок может отличаться, поэтому проверяем вхождение каждого)\n matches = [key in qr_string for key in required_keys]\n \n return all(matches)\n\ndef detect_and_decode_qr(image: np.ndarray) -> Optional[str]:\n \"\"\"\n Ищет ВСЕ QR-коды на изображении и возвращает только тот, \n который похож на фискальный чек.\n \"\"\"\n try:\n pil_img = Image.fromarray(image)\n \n # Декодируем все коды на картинке\n decoded_objects = decode(pil_img)\n \n if not decoded_objects:\n logger.info(\"No QR codes detected on the image.\")\n return None\n \n logger.info(f\"Detected {len(decoded_objects)} code(s). Scanning for fiscal data...\")\n\n for obj in decoded_objects:\n if obj.type == 'QRCODE':\n qr_data = obj.data.decode(\"utf-8\")\n \n # Логируем найденное (для отладки, если вдруг формат хитрый)\n # Обрезаем длинные строки, чтобы не засорять лог\n log_preview = (qr_data[:75] + '..') if len(qr_data) > 75 else qr_data\n logger.info(f\"Checking QR content: {log_preview}\")\n \n if is_valid_fiscal_qr(qr_data):\n logger.info(\"Valid fiscal QR found!\")\n return qr_data\n else:\n logger.info(\"QR skipped (not a fiscal receipt pattern).\")\n \n logger.warning(\"QR codes were found, but none matched the fiscal receipt format.\")\n return None\n\n except Exception as e:\n logger.error(f\"Error during QR detection: {e}\")\n return None\n\ndef fetch_data_from_api(qr_raw: str) -> List[ParsedItem]:\n \"\"\"\n Отправляет данные QR-кода в API proverkacheka.com и парсит JSON-ответ.\n \"\"\"\n try:\n payload = {\n 'qrraw': qr_raw,\n 'token': API_TOKEN\n }\n \n logger.info(\"Sending request to Check API...\")\n response = requests.post(API_URL, data=payload, timeout=10)\n \n if response.status_code != 200:\n logger.error(f\"API Error: Status {response.status_code}\")\n return []\n\n data = response.json()\n \n if data.get('code') != 1:\n logger.warning(f\"API returned non-success code: {data.get('code')}\")\n return []\n \n json_data = data.get('data', {}).get('json', {})\n items_data = json_data.get('items', [])\n \n parsed_items = []\n \n for item in items_data:\n price = float(item.get('price', 0)) / 100.0\n total_sum = float(item.get('sum', 0)) / 100.0\n quantity = float(item.get('quantity', 0))\n name = item.get('name', 'Unknown')\n \n parsed_items.append(ParsedItem(\n raw_name=name,\n amount=quantity,\n price=price,\n sum=total_sum\n ))\n \n return parsed_items\n\n except Exception as e:\n logger.error(f\"Error fetching/parsing API data: {e}\")\n return []", + "requirements.txt": "fastapi\nuvicorn\npython-multipart\npydantic\nnumpy\nopencv-python-headless\nrequests\npyzbar\npillow\ncertifi", + "scripts/collect_data_raw.py": "import os\nimport requests\nimport json\nimport mimetypes\n\n# Папка, куда вы положите фото чеков для теста\nINPUT_DIR = \"./test_receipts\"\n# Папка, куда сохраним сырой текст\nOUTPUT_DIR = \"./raw_outputs\"\n# Адрес запущенного OCR сервиса\nAPI_URL = \"http://10.25.100.250:5006/recognize\"\n\ndef process_images():\n if not os.path.exists(OUTPUT_DIR):\n os.makedirs(OUTPUT_DIR)\n\n if not os.path.exists(INPUT_DIR):\n os.makedirs(INPUT_DIR)\n print(f\"Папка {INPUT_DIR} создана. Положите туда фото чеков и перезапустите скрипт.\")\n return\n\n files = [f for f in os.listdir(INPUT_DIR) if f.lower().endswith(('.jpg', '.jpeg', '.png'))]\n \n if not files:\n print(f\"В папке {INPUT_DIR} нет изображений.\")\n return\n\n print(f\"Найдено {len(files)} файлов. Начинаю обработку...\")\n\n for filename in files:\n file_path = os.path.join(INPUT_DIR, filename)\n mime_type, _ = mimetypes.guess_type(file_path)\n \n print(f\"Processing {filename}...\", end=\" \")\n \n try:\n with open(file_path, 'rb') as f:\n files = {'image': (filename, f, mime_type or 'image/jpeg')}\n response = requests.post(API_URL, files=files, timeout=30)\n \n if response.status_code == 200:\n data = response.json()\n raw_text = data.get(\"raw_text\", \"\")\n source = data.get(\"source\", \"unknown\")\n \n # Сохраняем RAW текст\n out_name = f\"{filename}_RAW.txt\"\n with open(os.path.join(OUTPUT_DIR, out_name), \"w\", encoding=\"utf-8\") as out:\n out.write(f\"Source: {source}\\n\")\n out.write(\"=\"*20 + \"\\n\")\n out.write(raw_text)\n \n print(f\"OK ({source}) -> {out_name}\")\n else:\n print(f\"FAIL: {response.status_code} - {response.text}\")\n \n except Exception as e:\n print(f\"ERROR: {e}\")\n\nif __name__ == \"__main__\":\n process_images()", + "scripts/test_parsing_quality.py": "import os\nimport requests\nimport json\nimport mimetypes\n\n# Папка с фото\nINPUT_DIR = \"./test_receipts\"\n# Папка для результатов\nOUTPUT_DIR = \"./json_results\"\n# Адрес сервиса\nAPI_URL = \"http://10.25.100.250:5006/recognize\"\n\ndef test_parsing():\n if not os.path.exists(OUTPUT_DIR):\n os.makedirs(OUTPUT_DIR)\n\n if not os.path.exists(INPUT_DIR):\n print(f\"Папка {INPUT_DIR} не найдена.\")\n return\n\n files = [f for f in os.listdir(INPUT_DIR) if f.lower().endswith(('.jpg', '.jpeg', '.png'))]\n \n print(f\"Найдено {len(files)} файлов. Тестируем парсинг...\")\n\n for filename in files:\n file_path = os.path.join(INPUT_DIR, filename)\n mime_type, _ = mimetypes.guess_type(file_path)\n \n print(f\"Processing {filename}...\", end=\" \")\n \n try:\n with open(file_path, 'rb') as f:\n files = {'image': (filename, f, mime_type or 'image/jpeg')}\n response = requests.post(API_URL, files=files, timeout=60) # Увеличили таймаут для LLM\n \n if response.status_code == 200:\n data = response.json()\n items = data.get(\"items\", [])\n source = data.get(\"source\", \"unknown\")\n \n # Сохраняем JSON\n out_name = f\"{filename}_RESULT.json\"\n with open(os.path.join(OUTPUT_DIR, out_name), \"w\", encoding=\"utf-8\") as out:\n json.dump(data, out, ensure_ascii=False, indent=2)\n \n print(f\"OK ({source}) -> Found {len(items)} items\")\n else:\n print(f\"FAIL: {response.status_code} - {response.text}\")\n \n except Exception as e:\n print(f\"ERROR: {e}\")\n\nif __name__ == \"__main__\":\n test_parsing()", } diff --git a/ocr-service/requirements.txt b/ocr-service/requirements.txt index 95949b0..158deb6 100644 --- a/ocr-service/requirements.txt +++ b/ocr-service/requirements.txt @@ -4,8 +4,8 @@ python-multipart pydantic numpy opencv-python-headless -pytesseract requests pyzbar pillow -certifi \ No newline at end of file +certifi +openpyxl \ No newline at end of file diff --git a/ocr-service/scripts/collect_data_raw.py b/ocr-service/scripts/collect_data_raw.py new file mode 100644 index 0000000..24c234a --- /dev/null +++ b/ocr-service/scripts/collect_data_raw.py @@ -0,0 +1,67 @@ +import os +import requests +import json +import mimetypes + +# Папка, куда вы положите фото чеков для теста +INPUT_DIR = "./test_receipts" +# Папка, куда сохраним сырой текст +OUTPUT_DIR = "./raw_outputs" +# Адрес запущенного OCR сервиса +API_URL = "http://10.25.100.250:5006/recognize" + +def process_images(): + if not os.path.exists(OUTPUT_DIR): + os.makedirs(OUTPUT_DIR) + + if not os.path.exists(INPUT_DIR): + os.makedirs(INPUT_DIR) + print(f"Папка {INPUT_DIR} создана. Положите туда фото чеков и перезапустите скрипт.") + return + + files = [f for f in os.listdir(INPUT_DIR) if f.lower().endswith(('.jpg', '.jpeg', '.png', '.xlsx'))] + + if not files: + print(f"В папке {INPUT_DIR} нет изображений.") + return + + print(f"Найдено {len(files)} файлов. Начинаю обработку...") + + for filename in files: + file_path = os.path.join(INPUT_DIR, filename) + + # Явное определение mime_type для Excel файлов + if filename.lower().endswith('.xlsx'): + mime_type = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + else: + mime_type, _ = mimetypes.guess_type(file_path) + mime_type = mime_type or 'image/jpeg' + + print(f"Processing {filename}...", end=" ") + + try: + with open(file_path, 'rb') as f: + files = {'image': (filename, f, mime_type or 'image/jpeg')} + response = requests.post(API_URL, files=files, timeout=30) + + if response.status_code == 200: + data = response.json() + raw_text = data.get("raw_text", "") + source = data.get("source", "unknown") + + # Сохраняем RAW текст + out_name = f"{filename}_RAW.txt" + with open(os.path.join(OUTPUT_DIR, out_name), "w", encoding="utf-8") as out: + out.write(f"Source: {source}\n") + out.write("="*20 + "\n") + out.write(raw_text) + + print(f"OK ({source}) -> {out_name}") + else: + print(f"FAIL: {response.status_code} - {response.text}") + + except Exception as e: + print(f"ERROR: {e}") + +if __name__ == "__main__": + process_images() \ No newline at end of file diff --git a/ocr-service/scripts/test_parsing_quality.py b/ocr-service/scripts/test_parsing_quality.py new file mode 100644 index 0000000..a7aad92 --- /dev/null +++ b/ocr-service/scripts/test_parsing_quality.py @@ -0,0 +1,62 @@ +import os +import requests +import json +import mimetypes + +# Папка с фото/excel +INPUT_DIR = "./test_receipts" +# Папка для результатов +OUTPUT_DIR = "./json_results" +# Адрес сервиса +API_URL = "http://10.25.100.250:5006/recognize" + +def test_parsing(): + if not os.path.exists(OUTPUT_DIR): + os.makedirs(OUTPUT_DIR) + + if not os.path.exists(INPUT_DIR): + print(f"Папка {INPUT_DIR} не найдена.") + return + + files = [f for f in os.listdir(INPUT_DIR) if f.lower().endswith(('.jpg', '.jpeg', '.png', '.xlsx'))] + + print(f"Найдено {len(files)} файлов. Тестируем парсинг...") + + for filename in files: + file_path = os.path.join(INPUT_DIR, filename) + + # Определение MIME + if filename.lower().endswith('.xlsx'): + mime_type = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + else: + mime_type, _ = mimetypes.guess_type(file_path) + mime_type = mime_type or 'image/jpeg' + + print(f"Processing {filename} ({mime_type})...", end=" ") + + try: + with open(file_path, 'rb') as f: + files = {'image': (filename, f, mime_type)} + # Тайм-аут побольше, так как Excel + LLM может быть долгим + response = requests.post(API_URL, files=files, timeout=60) + + if response.status_code == 200: + data = response.json() + items = data.get("items", []) + source = data.get("source", "unknown") + doc_number = data.get("doc_number", "") + + # Сохраняем JSON + out_name = f"{filename}_RESULT.json" + with open(os.path.join(OUTPUT_DIR, out_name), "w", encoding="utf-8") as out: + json.dump(data, out, ensure_ascii=False, indent=2) + + print(f"OK ({source}) -> Found {len(items)} items. Doc#: {doc_number}") + else: + print(f"FAIL: {response.status_code} - {response.text}") + + except Exception as e: + print(f"ERROR: {e}") + +if __name__ == "__main__": + test_parsing() \ No newline at end of file diff --git a/rmser-view/.gitignore b/rmser-view/.gitignore index 2530b18..d4a8ce7 100644 --- a/rmser-view/.gitignore +++ b/rmser-view/.gitignore @@ -22,4 +22,5 @@ dist-ssr *.njsproj *.sln *.sw? -*.txt \ No newline at end of file +*.txt +*.py \ No newline at end of file