mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
88 lines
70 KiB
Python
88 lines
70 KiB
Python
# -*- coding: utf-8 -*-
|
||
# Этот файл сгенерирован автоматически.
|
||
# Содержит дерево проекта и файлы (.go, .json, .mod, .md) в экранированном виде.
|
||
|
||
project_tree = '''
|
||
.
|
||
├── cmd
|
||
│ └── main.go
|
||
├── config
|
||
│ └── config.go
|
||
├── config.yaml
|
||
├── go.mod
|
||
├── go.sum
|
||
├── internal
|
||
│ ├── domain
|
||
│ │ ├── catalog
|
||
│ │ │ └── entity.go
|
||
│ │ ├── interfaces.go
|
||
│ │ ├── invoices
|
||
│ │ │ └── entity.go
|
||
│ │ ├── operations
|
||
│ │ │ └── entity.go
|
||
│ │ ├── recipes
|
||
│ │ │ └── entity.go
|
||
│ │ └── recommendations
|
||
│ │ └── entity.go
|
||
│ ├── infrastructure
|
||
│ │ ├── db
|
||
│ │ │ └── postgres.go
|
||
│ │ ├── redis
|
||
│ │ │ └── client.go
|
||
│ │ ├── repository
|
||
│ │ │ ├── catalog
|
||
│ │ │ │ └── postgres.go
|
||
│ │ │ ├── invoices
|
||
│ │ │ │ └── postgres.go
|
||
│ │ │ ├── operations
|
||
│ │ │ │ └── postgres.go
|
||
│ │ │ ├── recipes
|
||
│ │ │ │ └── postgres.go
|
||
│ │ │ └── recommendations
|
||
│ │ │ └── postgres.go
|
||
│ │ └── rms
|
||
│ │ ├── client.go
|
||
│ │ └── dto.go
|
||
│ └── services
|
||
│ ├── recommend
|
||
│ │ └── service.go
|
||
│ └── sync
|
||
│ └── service.go
|
||
├── pack_go_files.py
|
||
└── pkg
|
||
└── logger
|
||
└── logger.go
|
||
'''
|
||
|
||
project_files = {
|
||
"cmd/main.go": "package main\n\nimport (\n\t\"log\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"go.uber.org/zap\"\n\n\t\"rmser/config\"\n\t\"rmser/internal/infrastructure/db\"\n\n\t// \"rmser/internal/infrastructure/redis\"\n\tcatalogPkg \"rmser/internal/infrastructure/repository/catalog\"\n\tinvoicesPkg \"rmser/internal/infrastructure/repository/invoices\"\n\topsRepoPkg \"rmser/internal/infrastructure/repository/operations\"\n\trecipesPkg \"rmser/internal/infrastructure/repository/recipes\"\n\trecRepoPkg \"rmser/internal/infrastructure/repository/recommendations\"\n\t\"rmser/internal/infrastructure/rms\"\n\trecServicePkg \"rmser/internal/services/recommend\"\n\t\"rmser/internal/services/sync\"\n\t\"rmser/pkg/logger\"\n)\n\nfunc main() {\n\t// 1. Загрузка конфигурации\n\tcfg, err := config.LoadConfig(\".\")\n\tif err != nil {\n\t\tlog.Fatalf(\"Ошибка загрузки конфига: %v\", err)\n\t}\n\n\t// 2. Инициализация логгера\n\tlogger.Init(cfg.App.Mode)\n\tdefer logger.Log.Sync()\n\n\tlogger.Log.Info(\"Запуск приложения rmser\", zap.String(\"mode\", cfg.App.Mode))\n\n\t// 3a. Подключение Redis (Новое)\n\t// redisClient, err := redis.NewClient(cfg.Redis.Addr, cfg.Redis.Password, cfg.Redis.DB)\n\t// if err != nil {\n\t// \tlogger.Log.Fatal(\"Ошибка подключения к Redis\", zap.Error(err))\n\t// }\n\n\t// 3. Подключение к БД (PostgreSQL)\n\tdatabase := db.NewPostgresDB(cfg.DB.DSN)\n\n\t// 4. Инициализация слоев\n\trmsClient := rms.NewClient(cfg.RMS.BaseURL, cfg.RMS.Login, cfg.RMS.Password)\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\n\tsyncService := sync.NewService(rmsClient, catalogRepo, recipesRepo, invoicesRepo, opsRepo)\n\trecService := recServicePkg.NewService(recRepo)\n\n\t// --- БЛОК ПРОВЕРКИ СИНХРОНИЗАЦИИ (Run-once on start) ---\n\tgo func() {\n\t\tlogger.Log.Info(\">>> Запуск тестовой синхронизации...\")\n\n\t\t// 1. Каталог\n\t\tif err := syncService.SyncCatalog(); err != nil {\n\t\t\tlogger.Log.Error(\"Ошибка синхронизации каталога\", zap.Error(err))\n\t\t} else {\n\t\t\tlogger.Log.Info(\"<<< Каталог успешно синхронизирован\")\n\t\t}\n\n\t\t// 2. Техкарты\n\t\tif err := syncService.SyncRecipes(); err != nil {\n\t\t\tlogger.Log.Error(\"Ошибка синхронизации техкарт\", zap.Error(err))\n\t\t} else {\n\t\t\tlogger.Log.Info(\"<<< Техкарты успешно синхронизированы\")\n\t\t}\n\n\t\t// 3. Накладные\n\t\tif err := syncService.SyncInvoices(); err != nil {\n\t\t\tlogger.Log.Error(\"Ошибка синхронизации накладных\", zap.Error(err))\n\t\t} else {\n\t\t\tlogger.Log.Info(\"<<< Накладные успешно синхронизированы\")\n\t\t}\n\t\t// 4. Складские операции\n\t\tif err := syncService.SyncStoreOperations(); err != nil {\n\t\t\tlogger.Log.Error(\"Ошибка синхронизации операций\", zap.Error(err))\n\t\t} else {\n\t\t\tlogger.Log.Info(\"<<< Операции успешно синхронизированы\")\n\t\t}\n\t\tlogger.Log.Info(\">>> Запуск расчета рекомендаций...\")\n\t\tif err := recService.RefreshRecommendations(); err != nil {\n\t\t\tlogger.Log.Error(\"Ошибка расчета рекомендаций\", zap.Error(err))\n\t\t} else {\n\t\t\t// Для отладки можно вывести пару штук\n\t\t\trecs, _ := recService.GetRecommendations()\n\t\t\tlogger.Log.Info(\"<<< Анализ завершен\", zap.Int(\"found\", len(recs)))\n\t\t}\n\t}()\n\t// -------------------------------------------------------\n\n\t// 5. Запуск HTTP сервера (Gin)\n\tif cfg.App.Mode == \"release\" {\n\t\tgin.SetMode(gin.ReleaseMode)\n\t}\n\tr := gin.Default()\n\n\t// Простой хелсчек\n\tr.GET(\"/health\", func(c *gin.Context) {\n\t\tc.JSON(200, gin.H{\n\t\t\t\"status\": \"ok\",\n\t\t\t\"time\": time.Now().Format(time.RFC3339),\n\t\t})\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: true\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\" # Пароль в открытом виде (приложение само хеширует)",
|
||
"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}\n\ntype AppConfig struct {\n\tPort string `mapstructure:\"port\"`\n\tMode string `mapstructure:\"mode\"` // debug/release\n\tDropTables bool `mapstructure:\"drop_tables\"`\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}\n\ntype RMSConfig struct {\n\tBaseURL string `mapstructure:\"base_url\"`\n\tLogin string `mapstructure:\"login\"`\n\tPassword string `mapstructure:\"password\"` // Исходный пароль, хеширование будет в клиенте\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.1\n\nrequire (\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\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.8 // 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.2 // 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-20180228061459-e0a39a4cb421 // 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/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// Product - Номенклатура\ntype Product struct {\n\tID uuid.UUID `gorm:\"type:uuid;primary_key;\"`\n\tParentID *uuid.UUID `gorm:\"type:uuid;index\"`\n\tName string `gorm:\"type:varchar(255);not null\"`\n\tType string `gorm:\"type:varchar(50);index\"` // GOODS, DISH, PREPARED, etc.\n\tNum string `gorm:\"type:varchar(50)\"`\n\tCode string `gorm:\"type:varchar(50)\"`\n\tUnitWeight decimal.Decimal `gorm:\"type:numeric(19,4)\"`\n\tUnitCapacity decimal.Decimal `gorm:\"type:numeric(19,4)\"`\n\tIsDeleted bool `gorm:\"default:false\"`\n\n\tParent *Product `gorm:\"foreignKey:ParentID\"`\n\tChildren []*Product `gorm:\"foreignKey:ParentID\"`\n\n\tCreatedAt time.Time\n\tUpdatedAt time.Time\n}\n\n// Repository интерфейс для каталога\ntype Repository interface {\n\tSaveProducts(products []Product) error\n\tGetAll() ([]Product, 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\tDocumentNumber string `gorm:\"type:varchar(100);index\"`\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\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\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\tGetLastInvoiceDate() (*time.Time, error)\n\tSaveInvoices(invoices []Invoice) 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\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, opType OperationType, dateFrom, dateTo time.Time) 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\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\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/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/catalog\"\n\t\"rmser/internal/domain/invoices\"\n\t\"rmser/internal/domain/operations\"\n\t\"rmser/internal/domain/recipes\"\n\t\"rmser/internal/domain/recommendations\"\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&catalog.Product{},\n\t\t&recipes.Recipe{},\n\t\t&recipes.RecipeItem{},\n\t\t&invoices.Invoice{},\n\t\t&invoices.InvoiceItem{},\n\t\t&operations.StoreOperation{},\n\t\t&recommendations.Recommendation{},\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/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/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\nfunc (r *pgRepository) SaveProducts(products []catalog.Product) error {\n\t// Сортировка (родители -> дети)\n\tsorted := sortProductsByHierarchy(products)\n\treturn r.db.Clauses(clause.OnConflict{\n\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\tUpdateAll: true,\n\t}).CreateInBatches(sorted, 100).Error\n}\n\nfunc (r *pgRepository) GetAll() ([]catalog.Product, error) {\n\tvar products []catalog.Product\n\terr := r.db.Find(&products).Error\n\treturn products, err\n}\n\n// Вспомогательная функция сортировки\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",
|
||
"internal/infrastructure/repository/invoices/postgres.go": "package invoices\n\nimport (\n\t\"time\"\n\n\t\"rmser/internal/domain/invoices\"\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) invoices.Repository {\n\treturn &pgRepository{db: db}\n}\n\nfunc (r *pgRepository) GetLastInvoiceDate() (*time.Time, error) {\n\tvar inv invoices.Invoice\n\terr := r.db.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) 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\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",
|
||
"internal/infrastructure/repository/operations/postgres.go": "package operations\n\nimport (\n\t\"time\"\n\n\t\"rmser/internal/domain/operations\"\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, opType operations.OperationType, dateFrom, dateTo time.Time) error {\n\treturn r.db.Transaction(func(tx *gorm.DB) error {\n\t\t// 1. Удаляем старые записи этого типа, которые пересекаются с периодом.\n\t\t// Так как отчет агрегированный, мы привязываемся к периоду \"с\" и \"по\".\n\t\t// Упрощение: удаляем всё, где PeriodFrom совпадает с текущей выгрузкой,\n\t\t// предполагая, что мы всегда грузим одними и теми же квантами (например, месяц или неделя).\n\t\t// Для надежности удалим всё, что попадает в диапазон.\n\t\tif err := tx.Where(\"op_type = ? AND period_from >= ? AND period_to <= ?\", opType, dateFrom, dateTo).\n\t\t\tDelete(&operations.StoreOperation{}).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// 2. Вставляем новые\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/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\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/rms/client.go": "package rms\n\nimport (\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/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\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}\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\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\tIsDeleted: p.Deleted,\n\t\t})\n\t}\n\n\treturn products, 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\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",
|
||
"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\tDeleted bool `json:\"deleted\"`\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// --- 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\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",
|
||
"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\"go.uber.org/zap\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/shopspring/decimal\"\n\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/infrastructure/rms\"\n\t\"rmser/pkg/logger\"\n)\n\nconst (\n\t// Пресеты от пользователя\n\tPresetPurchases = \"1a3297e1-cb05-55dc-98a7-c13f13bc85a7\" // Закупки\n\tPresetUsage = \"24d9402e-2d01-eca1-ebeb-7981f7d1cb86\" // Расход\n)\n\ntype Service struct {\n\trmsClient rms.ClientI\n\tcatalogRepo catalog.Repository\n\trecipeRepo recipes.Repository\n\tinvoiceRepo invoices.Repository\n\topRepo operations.Repository\n}\n\nfunc NewService(\n\trmsClient rms.ClientI,\n\tcatalogRepo catalog.Repository,\n\trecipeRepo recipes.Repository,\n\tinvoiceRepo invoices.Repository,\n\topRepo operations.Repository,\n) *Service {\n\treturn &Service{\n\t\trmsClient: rmsClient,\n\t\tcatalogRepo: catalogRepo,\n\t\trecipeRepo: recipeRepo,\n\t\tinvoiceRepo: invoiceRepo,\n\t\topRepo: opRepo,\n\t}\n}\n\n// SyncCatalog загружает номенклатуру и сохраняет в БД\nfunc (s *Service) SyncCatalog() error {\n\tlogger.Log.Info(\"Начало синхронизации номенклатуры\")\n\n\tproducts, err := s.rmsClient.FetchCatalog()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"ошибка получения каталога из RMS: %w\", err)\n\t}\n\n\tif err := s.catalogRepo.SaveProducts(products); err != nil {\n\t\treturn fmt.Errorf(\"ошибка сохранения продуктов в БД: %w\", err)\n\t}\n\n\tlogger.Log.Info(\"Синхронизация номенклатуры завершена\", zap.Int(\"count\", len(products)))\n\treturn nil\n}\n\n// SyncRecipes загружает техкарты за указанный период (или за последние 30 дней по умолчанию)\nfunc (s *Service) SyncRecipes() error {\n\tlogger.Log.Info(\"Начало синхронизации техкарт\")\n\n\t// RMS требует dateFrom. Берем широкий диапазон, например, с начала года или фиксированную дату,\n\t// либо можно сделать конфигурируемым. Для примера берем -3 месяца от текущей даты.\n\t// В реальном проде лучше брать дату последнего изменения, если API поддерживает revision,\n\t// но V2 API iiko часто требует полной перезагрузки актуальных карт.\n\tdateFrom := time.Now().AddDate(0, -3, 0)\n\tdateTo := time.Now() // +1 месяц вперед на случай будущих меню\n\n\trecipes, err := s.rmsClient.FetchRecipes(dateFrom, dateTo)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"ошибка получения техкарт из RMS: %w\", err)\n\t}\n\n\tif err := s.recipeRepo.SaveRecipes(recipes); err != nil {\n\t\treturn fmt.Errorf(\"ошибка сохранения техкарт в БД: %w\", err)\n\t}\n\n\tlogger.Log.Info(\"Синхронизация техкарт завершена\", zap.Int(\"count\", len(recipes)))\n\treturn nil\n}\n\n// SyncInvoices загружает накладные. Если в базе пусто, грузит за последние N дней.\nfunc (s *Service) SyncInvoices() error {\n\tlogger.Log.Info(\"Начало синхронизации накладных\")\n\n\tlastDate, err := s.invoiceRepo.GetLastInvoiceDate()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"ошибка получения даты последней накладной: %w\", err)\n\t}\n\n\tvar from time.Time\n\tto := time.Now()\n\n\tif lastDate != nil {\n\t\t// Берем следующий день после последней загрузки или тот же день, чтобы обновить изменения\n\t\tfrom = *lastDate\n\t} else {\n\t\t// Дефолтная загрузка за 30 дней назад\n\t\tfrom = time.Now().AddDate(0, 0, -30)\n\t}\n\n\tlogger.Log.Info(\"Запрос накладных\", zap.Time(\"from\", from), zap.Time(\"to\", to))\n\n\tinvoices, err := s.rmsClient.FetchInvoices(from, to)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"ошибка получения накладных из RMS: %w\", err)\n\t}\n\n\tif len(invoices) == 0 {\n\t\tlogger.Log.Info(\"Новых накладных не найдено\")\n\t\treturn nil\n\t}\n\n\tif err := s.invoiceRepo.SaveInvoices(invoices); err != nil {\n\t\treturn fmt.Errorf(\"ошибка сохранения накладных в БД: %w\", err)\n\t}\n\n\tlogger.Log.Info(\"Синхронизация накладных завершена\", zap.Int(\"count\", len(invoices)))\n\treturn nil\n}\n\n// classifyOperation определяет тип операции на основе DocumentType\nfunc classifyOperation(docType string) operations.OperationType {\n\tswitch docType {\n\t// === ПРИХОД (PURCHASE) ===\n\tcase \"INCOMING_INVOICE\": // Приходная накладная\n\t\treturn operations.OpTypePurchase\n\tcase \"INCOMING_SERVICE\": // Акт приема услуг (редко товары, но бывает)\n\t\treturn operations.OpTypePurchase\n\n\t// === РАСХОД (USAGE) ===\n\tcase \"SALES_DOCUMENT\": // Акт реализации (продажа)\n\t\treturn operations.OpTypeUsage\n\tcase \"WRITEOFF_DOCUMENT\": // Акт списания (порча, проработки)\n\t\treturn operations.OpTypeUsage\n\tcase \"OUTGOING_INVOICE\": // Расходная накладная\n\t\treturn operations.OpTypeUsage\n\tcase \"SESSION_ACCEPTANCE\": // Принятие смены (иногда агрегирует продажи)\n\t\treturn operations.OpTypeUsage\n\tcase \"DISASSEMBLE_DOCUMENT\": // Акт разбора (расход целого)\n\t\treturn operations.OpTypeUsage\n\n\t// === Спорные/Игнорируемые ===\n\t// RETURNED_INVOICE (Возвратная накладная) - технически это уменьшение прихода,\n\t// но для рекомендаций \"что мы покупаем\" лучше обрабатывать отдельно или как минус-purchase.\n\t// Пока отнесем к UNKNOWN, чтобы не портить статистику чистого прихода,\n\t// либо можно считать как Purchase с отрицательным Amount (если XML дает минус).\n\tcase \"RETURNED_INVOICE\":\n\t\treturn operations.OpTypeUnknown\n\n\tcase \"INTERNAL_TRANSFER\":\n\t\treturn operations.OpTypeUnknown // Перемещение нас не интересует в рамках рекомендаций \"купил/продал\"\n\tcase \"INCOMING_INVENTORY\":\n\t\treturn operations.OpTypeUnknown // Инвентаризация\n\n\tdefault:\n\t\treturn operations.OpTypeUnknown\n\t}\n}\n\nfunc (s *Service) SyncStoreOperations() error {\n\tdateTo := time.Now()\n\tdateFrom := dateTo.AddDate(0, 0, -30)\n\n\t// 1. Синхронизируем Закупки (PresetPurchases)\n\t// Мы передаем OpTypePurchase, чтобы репозиторий знал, какую \"полку\" очистить перед записью.\n\tif err := s.syncReport(PresetPurchases, operations.OpTypePurchase, dateFrom, dateTo); err != nil {\n\t\treturn fmt.Errorf(\"ошибка синхронизации закупок: %w\", err)\n\t}\n\n\t// 2. Синхронизируем Расход (PresetUsage)\n\tif err := s.syncReport(PresetUsage, operations.OpTypeUsage, dateFrom, dateTo); err != nil {\n\t\treturn fmt.Errorf(\"ошибка синхронизации расхода: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc (s *Service) syncReport(presetID string, targetOpType operations.OperationType, from, to time.Time) error {\n\tlogger.Log.Info(\"Запрос отчета RMS\", zap.String(\"preset\", presetID))\n\n\titems, err := s.rmsClient.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\t// 1. Валидация товара\n\t\tpID, err := uuid.Parse(item.ProductID)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\t// 2. Определение реального типа операции\n\t\trealOpType := classifyOperation(item.DocumentType)\n\n\t\t// 3. Фильтрация \"мусора\"\n\t\t// Если мы грузим отчет \"Закупки\", но туда попало \"Перемещение\" (из-за кривого пресета),\n\t\t// мы это пропустим. Либо если документ неизвестного типа.\n\t\tif realOpType == operations.OpTypeUnknown {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Важно: Мы сохраняем только то, что соответствует целевому типу этапа синхронизации.\n\t\t// Если в пресете \"Закупки\" попалась \"Реализация\", мы не должны писать её в \"Закупки\",\n\t\t// и не должны писать в \"Расход\" (так как мы сейчас чистим \"Закупки\").\n\t\tif realOpType != targetOpType {\n\t\t\tcontinue\n\t\t}\n\n\t\tops = append(ops, operations.StoreOperation{\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\tif err := s.opRepo.SaveOperations(ops, targetOpType, from, to); err != nil {\n\t\treturn err\n\t}\n\n\tlogger.Log.Info(\"Отчет сохранен\",\n\t\tzap.String(\"op_type\", string(targetOpType)),\n\t\tzap.Int(\"received\", len(items)),\n\t\tzap.Int(\"saved\", len(ops)))\n\treturn 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}')
|