From d904db216f2bf5ecdc838de66e97445eda291163 Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Sat, 7 Feb 2026 21:23:23 +0300 Subject: [PATCH] Remove admin pricing stack and prepare v1.0.4 release --- CLAUDE.md | 194 +- README.md | 26 +- cmd/cron/main.go | 84 - cmd/importer/main.go | 160 -- cmd/qfs/main.go | 228 +- internal/handlers/component.go | 97 +- internal/handlers/pricelist.go | 406 +-- internal/handlers/pricing.go | 1420 ----------- internal/handlers/sync.go | 154 +- internal/handlers/web.go | 6 +- internal/localdb/localdb.go | 101 + internal/repository/pricelist.go | 44 - internal/services/alerts/service.go | 199 -- internal/services/local_configuration.go | 36 +- internal/services/pricelist/service.go | 371 --- .../pricelist/service_warehouse_test.go | 72 - internal/services/pricing/calculator.go | 121 - internal/services/pricing/service.go | 378 --- internal/services/quote.go | 56 +- internal/services/stock_import.go | 1085 -------- internal/services/stock_import_test.go | 384 --- internal/services/sync/worker.go | 9 + internal/warehouse/snapshot.go | 219 -- internal/warehouse/snapshot_test.go | 103 - scripts/release.sh | 19 + web/templates/admin_pricing.html | 2179 ----------------- web/templates/base.html | 35 +- web/templates/partials/sync_status.html | 9 +- 28 files changed, 611 insertions(+), 7584 deletions(-) delete mode 100644 cmd/cron/main.go delete mode 100644 cmd/importer/main.go delete mode 100644 internal/handlers/pricing.go delete mode 100644 internal/services/alerts/service.go delete mode 100644 internal/services/pricelist/service.go delete mode 100644 internal/services/pricelist/service_warehouse_test.go delete mode 100644 internal/services/pricing/calculator.go delete mode 100644 internal/services/pricing/service.go delete mode 100644 internal/services/stock_import.go delete mode 100644 internal/services/stock_import_test.go delete mode 100644 internal/warehouse/snapshot.go delete mode 100644 internal/warehouse/snapshot_test.go delete mode 100644 web/templates/admin_pricing.html diff --git a/CLAUDE.md b/CLAUDE.md index 6b40ccc..f8fc24e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,163 +1,71 @@ # QuoteForge - Claude Code Instructions ## Overview -Корпоративный конфигуратор серверов и формирование КП. MariaDB (RFQ_LOG) + SQLite для оффлайн. +Корпоративный конфигуратор серверов с offline-first архитектурой. +Приложение работает через локальную SQLite базу, синхронизация с MariaDB выполняется фоново. -## Development Phases +## Product Scope +- Конфигуратор компонентов и расчёт КП +- Проекты и конфигурации +- Read-only просмотр прайслистов из локального кэша +- Sync (pull компонентов/прайслистов, push локальных изменений) -### Phase 1: Pricelists in MariaDB ✅ DONE -### Phase 2: Local SQLite Database ✅ DONE +Из области исключены: +- admin pricing UI/API +- stock import +- alerts +- cron/importer утилиты -### Phase 2.5: Full Offline Mode 🔶 IN PROGRESS -**Local-first architecture:** приложение ВСЕГДА работает с SQLite, MariaDB только для синхронизации. +## Architecture +- Local-first: чтение и запись происходят в SQLite +- MariaDB используется как сервер синхронизации +- Background worker: периодический sync push+pull -**Принцип работы:** -- ВСЕ операции (CRUD) выполняются в SQLite -- При создании конфигурации: - 1. Если online → проверить новые прайслисты на сервере → скачать если есть - 2. Далее работаем с local_pricelists (и online, и offline одинаково) -- Background sync: push pending_changes → pull updates - -**DONE:** -- ✅ Sync queue table (pending_changes) - `internal/localdb/models.go` -- ✅ Model converters: MariaDB ↔ SQLite - `internal/localdb/converters.go` -- ✅ LocalConfigurationService: все CRUD через SQLite - `internal/services/local_configuration.go` -- ✅ Pre-create pricelist check: `SyncPricelistsIfNeeded()` - `internal/services/sync/service.go` -- ✅ Push pending changes: `PushPendingChanges()` - sync service + handlers -- ✅ Sync API endpoints: `/api/sync/push`, `/pending/count`, `/pending` -- ✅ Integrate LocalConfigurationService in main.go (replace ConfigurationService) -- ✅ Add routes for new sync endpoints (`/api/sync/push`, `/pending/count`, `/pending`) -- ✅ ConfigurationGetter interface for handler compatibility -- ✅ Background sync worker: auto-sync every 5min (push + pull) - `internal/services/sync/worker.go` -- ✅ UI: sync status indicator (pending badge + sync button + offline/online dot) - `web/templates/partials/sync_status.html` -- ✅ RefreshPrices for local mode: - - `RefreshPrices()` / `RefreshPricesNoAuth()` в `local_configuration.go` - - Берёт цены из `local_components.current_price` - - Graceful degradation при отсутствии компонента - - Добавлено поле `price_updated_at` в `LocalConfiguration` (models.go:72) - - Обновлены converters для PriceUpdatedAt - - UI кнопка "Пересчитать цену" работает offline/online -- ✅ Fixed sync bugs: - - Duplicate entry error при update конфигураций (`sync/service.go:334-365`) - - pushConfigurationUpdate теперь проверяет наличие server_id перед update - - Если нет ID → получает из LocalConfiguration.ServerID или ищет на сервере - - Fixed setup.go: `settings.Password` → `settings.PasswordEncrypted` - -**TODO:** -- ❌ Conflict resolution (Phase 4, last-write-wins default) - -### UI Improvements ✅ MOSTLY DONE - -**1. Sync UI + pricelist badge: ✅ DONE** -- ✅ `sync_status.html`: SVG иконки Online/Offline (кликабельные → открывают модал) -- ✅ Кнопка sync → иконка circular arrows (только full sync) -- ✅ Модальное окно "Статус системы" в `base.html` (info о БД, ошибки синхронизации) -- ✅ `configs.html`: badge с версией активного прайслиста -- ✅ Загрузка через `/api/pricelists/latest` при DOMContentLoaded -- ✅ Удалён dropdown с Push changes (упрощение UI) - -**2. Прайслисты → вкладка в "Администратор цен": ✅ DONE** -- ✅ `base.html`: убрана ссылка "Прайслисты" из навигации -- ✅ `admin_pricing.html`: добавлена вкладка "Прайслисты" -- ✅ Логика перенесена из `pricelists.html` (table, create modal, CRUD) -- ✅ Route `/pricelists` → редирект на `/admin/pricing?tab=pricelists` -- ✅ Поддержка URL param `?tab=pricelists` - -**3. Модал "Настройка цены" - кол-во котировок с учётом периода: ❌ TODO** -- Текущее: показывает только общее кол-во котировок -- Новое: показывать `N (всего: M)` где N - за выбранный период, M - всего -- ❌ `admin_pricing.html`: обновить `#modal-quote-count` -- ❌ `admin_pricing_handler.go`: в `/api/admin/pricing/preview` возвращать `quote_count_period` + `quote_count_total` - -**4. Страница настроек: ❌ ОТЛОЖЕНО** -- Перенесено в Phase 3 (после основных UI улучшений) - -### Phase 3: Projects and Specifications -- qt_projects, qt_specifications tables (MariaDB) -- Replace qt_configurations → Project/Specification hierarchy -- Fields: opty, customer_requirement, variant, qty, rev -- Local projects/specs with server sync - -### Phase 4: Price Versioning -- Bind specifications to pricelist versions -- Price diff comparison -- Auto-cleanup expired pricelists (>1 year, usage_count=0) - -## Tech Stack -Go 1.22+ | Gin | GORM | MariaDB 11 | SQLite (glebarez/sqlite) | htmx + Tailwind CDN | excelize - -## Key Tables - -### READ-ONLY (external systems) -- `lot` (lot_name PK, lot_description) -- `lot_log` (lot, supplier, date, price, quality, comments) -- `supplier` (supplier_name PK) - -### MariaDB (qt_* prefix) -- `qt_lot_metadata` - component prices, methods, popularity -- `qt_categories` - category codes and names -- `qt_pricelists` - version snapshots (YYYY-MM-DD-NNN format) -- `qt_pricelist_items` - prices per pricelist -- `qt_projects` - uuid, opty, customer_requirement, name (Phase 3) -- `qt_specifications` - project_id, pricelist_id, variant, rev, qty, items JSON (Phase 3) - -### SQLite (data/quoteforge.db) -- `connection_settings` - encrypted DB credentials (PasswordEncrypted field) -- `local_pricelists/items` - cached from server -- `local_components` - lot cache for offline search (with current_price) -- `local_configurations` - UUID, items, price_updated_at, sync_status (pending/synced/conflict), server_id -- `local_projects/specifications` - Phase 3 -- `pending_changes` - sync queue (entity_type, uuid, op, payload, created_at, attempts, last_error) - -## Business Logic - -**Part number parsing:** `CPU_AMD_9654` → category=`CPU`, model=`AMD_9654` - -**Price methods:** manual | median | average | weighted_median - -**Price freshness:** fresh (<30d, ≥3 quotes) | normal (<60d) | stale (<90d) | critical - -**Pricelist version:** `YYYY-MM-DD-NNN` (e.g., `2024-01-31-001`) +## Key SQLite Data +- `connection_settings` +- `local_components` +- `local_pricelists`, `local_pricelist_items` +- `local_configurations` +- `local_projects` +- `pending_changes` ## API Endpoints - | Group | Endpoints | |-------|-----------| -| Setup | GET/POST /setup, POST /setup/test | -| Components | GET /api/components, /api/categories | -| Pricelists | CRUD /api/pricelists, GET /latest, POST /compare | -| Projects | CRUD /api/projects/:uuid (Phase 3) | -| Specs | CRUD /api/specs/:uuid, POST /upgrade, GET /diff (Phase 3) | -| Configs | POST /:uuid/refresh-prices (обновить цены из local_components) | -| Sync | GET /status, POST /components, /pricelists, /push, /pull, /resolve-conflict | -| Export | GET /api/specs/:uuid/export, /api/projects/:uuid/export | +| Setup | `GET /setup`, `POST /setup`, `POST /setup/test`, `GET /setup/status` | +| Components | `GET /api/components`, `GET /api/components/:lot_name`, `GET /api/categories` | +| Quote | `POST /api/quote/validate`, `POST /api/quote/calculate`, `POST /api/quote/price-levels` | +| Pricelists (read-only) | `GET /api/pricelists`, `GET /api/pricelists/latest`, `GET /api/pricelists/:id`, `GET /api/pricelists/:id/items`, `GET /api/pricelists/:id/lots` | +| Configs | CRUD + refresh/clone/reactivate/rename/project binding via `/api/configs/*` | +| Projects | CRUD + nested configs via `/api/projects/*` | +| Sync | `GET /api/sync/status`, `GET /api/sync/readiness`, `GET /api/sync/info`, `GET /api/sync/users-status`, `POST /api/sync/components`, `POST /api/sync/pricelists`, `POST /api/sync/all`, `POST /api/sync/push`, `GET /api/sync/pending`, `GET /api/sync/pending/count` | +| Export | `POST /api/export/csv` | + +## Web Routes +- `/configs` +- `/configurator` +- `/projects` +- `/projects/:uuid` +- `/pricelists` +- `/pricelists/:id` +- `/setup` ## Commands ```bash # Development -go run ./cmd/qfs # Dev server -make run # Dev server (via Makefile) +go run ./cmd/qfs +make run -# Production build -make build-release # Optimized build with version (recommended) -VERSION=$(git describe --tags --always --dirty) -CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=$VERSION" -o bin/qfs ./cmd/qfs +# Build +make build-release +CGO_ENABLED=0 go build -o bin/qfs ./cmd/qfs -# Cron jobs -go run ./cmd/cron -job=cleanup-pricelists # Remove old unused pricelists -go run ./cmd/cron -job=update-prices # Recalculate all prices -go run ./cmd/cron -job=update-popularity # Update popularity scores - -# Check version -./bin/qfs -version +# Verification +go build ./cmd/qfs +go vet ./... ``` ## Code Style -- gofmt, structured logging (slog), wrap errors with context -- snake_case files, PascalCase types -- RBAC disabled: DB username = user_id via `models.EnsureDBUser()` - -## UI Guidelines -- htmx (hx-get/post/target/swap), Tailwind CDN -- Freshness colors: green (fresh) → yellow → orange → red (critical) -- Sync status + offline indicator in header +- gofmt +- structured logging (`slog`) +- explicit error wrapping with context diff --git a/README.md b/README.md index 60905c5..f16991d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ **Server Configuration & Quotation Tool** -QuoteForge — корпоративный инструмент для конфигурирования серверов и формирования коммерческих предложений (КП). Приложение интегрируется с существующей базой данных RFQ_LOG. +QuoteForge — корпоративный инструмент для конфигурирования серверов и формирования коммерческих предложений (КП). +Приложение работает в strict local-first режиме: пользовательские операции выполняются через локальную SQLite, MariaDB используется для синхронизации и серверного администрирования прайслистов. ![Go Version](https://img.shields.io/badge/Go-1.22+-00ADD8?style=flat&logo=go) ![License](https://img.shields.io/badge/License-Proprietary-red) @@ -16,6 +17,8 @@ QuoteForge — корпоративный инструмент для конфи - 💰 **Автоматический расчёт цен** — актуальные цены на основе истории закупок - 📊 **Экспорт в CSV/XLSX** — готовые спецификации для клиентов - 💾 **Сохранение конфигураций** — история и шаблоны для повторного использования +- 🔌 **Полная офлайн-работа** — можно продолжать работу без сети и синхронизировать позже +- 🛡️ **Защищенная синхронизация** — sync блокируется preflight-проверкой, если локальная схема не готова ### Для ценовых администраторов - 📈 **Умный расчёт цен** — медиана, взвешенная медиана, среднее @@ -35,7 +38,7 @@ QuoteForge — корпоративный инструмент для конфи - **Backend:** Go 1.22+, Gin, GORM - **Frontend:** HTML, Tailwind CSS, htmx -- **Database:** MariaDB 11+ +- **Database:** SQLite (runtime/local-first), MariaDB 11+ (sync + server admin) - **Export:** excelize (XLSX), encoding/csv ## Требования @@ -207,6 +210,18 @@ make help # Показать все команды Можно переопределить путь через `-localdb` или переменную окружения `QFS_DB_PATH`. +#### Sync readiness guard + +Перед `push/pull` выполняется preflight-проверка: +- доступен ли сервер (MariaDB); +- можно ли проверить и применить централизованные миграции локальной БД; +- подходит ли версия приложения под `min_app_version` миграций. + +Если проверка не пройдена: +- локальная работа (CRUD) продолжается; +- sync API возвращает `423 Locked` с `reason_code` и `reason_text`; +- в UI показывается красный индикатор и причина блокировки в модалке синхронизации. + ### Версионность конфигураций (local-first) Для `local_configurations` используется append-only versioning через полные snapshot-версии: @@ -301,6 +316,13 @@ GET /api/configs/:uuid/versions # Список версий конф GET /api/configs/:uuid/versions/:version # Получить конкретную версию POST /api/configs/:uuid/rollback # Rollback на указанную версию POST /api/configs/:uuid/reactivate # Вернуть архивную конфигурацию в активные +GET /api/sync/readiness # Статус readiness guard (ready|blocked|unknown) +GET /api/sync/status # Сводный статус синхронизации +GET /api/sync/info # Данные для модалки синхронизации +POST /api/sync/push # Push pending changes (423, если blocked) +POST /api/sync/all # Full sync push+pull (423, если blocked) +POST /api/sync/components # Pull components (423, если blocked) +POST /api/sync/pricelists # Pull pricelists (423, если blocked) ``` #### Sync payload для versioning diff --git a/cmd/cron/main.go b/cmd/cron/main.go deleted file mode 100644 index cade1be..0000000 --- a/cmd/cron/main.go +++ /dev/null @@ -1,84 +0,0 @@ -package main - -import ( - "flag" - "log" - - "git.mchus.pro/mchus/quoteforge/internal/config" - "git.mchus.pro/mchus/quoteforge/internal/models" - "git.mchus.pro/mchus/quoteforge/internal/repository" - "git.mchus.pro/mchus/quoteforge/internal/services/alerts" - "git.mchus.pro/mchus/quoteforge/internal/services/pricing" - "gorm.io/driver/mysql" - "gorm.io/gorm" - "gorm.io/gorm/logger" -) - -func main() { - configPath := flag.String("config", "config.yaml", "path to config file") - cronJob := flag.String("job", "", "type of cron job to run (alerts, update-prices)") - flag.Parse() - - cfg, err := config.Load(*configPath) - if err != nil { - log.Fatalf("Failed to load config: %v", err) - } - - db, err := gorm.Open(mysql.Open(cfg.Database.DSN()), &gorm.Config{ - Logger: logger.Default.LogMode(logger.Silent), - }) - if err != nil { - log.Fatalf("Failed to connect to database: %v", err) - } - - // Ensure tables exist - if err := models.Migrate(db); err != nil { - log.Fatalf("Migration failed: %v", err) - } - - // Initialize repositories - statsRepo := repository.NewStatsRepository(db) - alertRepo := repository.NewAlertRepository(db) - componentRepo := repository.NewComponentRepository(db) - priceRepo := repository.NewPriceRepository(db) - - // Initialize services - alertService := alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing) - pricingService := pricing.NewService(componentRepo, priceRepo, cfg.Pricing) - - switch *cronJob { - case "alerts": - log.Println("Running alerts check...") - if err := alertService.CheckAndGenerateAlerts(); err != nil { - log.Printf("Error running alerts check: %v", err) - } else { - log.Println("Alerts check completed successfully") - } - case "update-prices": - log.Println("Recalculating all prices...") - updated, errors := pricingService.RecalculateAllPrices() - log.Printf("Prices recalculated: %d updated, %d errors", updated, errors) - case "reset-counters": - log.Println("Resetting usage counters...") - if err := statsRepo.ResetWeeklyCounters(); err != nil { - log.Printf("Error resetting weekly counters: %v", err) - } - if err := statsRepo.ResetMonthlyCounters(); err != nil { - log.Printf("Error resetting monthly counters: %v", err) - } - log.Println("Usage counters reset completed") - case "update-popularity": - log.Println("Updating popularity scores...") - if err := statsRepo.UpdatePopularityScores(); err != nil { - log.Printf("Error updating popularity scores: %v", err) - } else { - log.Println("Popularity scores updated successfully") - } - default: - log.Println("No valid cron job specified. Available jobs:") - log.Println(" - alerts: Check and generate alerts") - log.Println(" - update-prices: Recalculate all prices") - log.Println(" - reset-counters: Reset usage counters") - log.Println(" - update-popularity: Update popularity scores") - } -} diff --git a/cmd/importer/main.go b/cmd/importer/main.go deleted file mode 100644 index 109a993..0000000 --- a/cmd/importer/main.go +++ /dev/null @@ -1,160 +0,0 @@ -package main - -import ( - "flag" - "log" - "strings" - - "git.mchus.pro/mchus/quoteforge/internal/config" - "git.mchus.pro/mchus/quoteforge/internal/models" - "gorm.io/driver/mysql" - "gorm.io/gorm" - "gorm.io/gorm/logger" -) - -func main() { - configPath := flag.String("config", "config.yaml", "path to config file") - flag.Parse() - - cfg, err := config.Load(*configPath) - if err != nil { - log.Fatalf("Failed to load config: %v", err) - } - - db, err := gorm.Open(mysql.Open(cfg.Database.DSN()), &gorm.Config{ - Logger: logger.Default.LogMode(logger.Silent), - }) - if err != nil { - log.Fatalf("Failed to connect to database: %v", err) - } - - log.Println("Connected to database") - - // Ensure tables exist - if err := models.Migrate(db); err != nil { - log.Fatalf("Migration failed: %v", err) - } - if err := models.SeedCategories(db); err != nil { - log.Fatalf("Seeding categories failed: %v", err) - } - - // Load categories for lookup - var categories []models.Category - db.Find(&categories) - categoryMap := make(map[string]uint) - for _, c := range categories { - categoryMap[c.Code] = c.ID - } - log.Printf("Loaded %d categories", len(categories)) - - // Get all lots - var lots []models.Lot - if err := db.Find(&lots).Error; err != nil { - log.Fatalf("Failed to load lots: %v", err) - } - log.Printf("Found %d lots to import", len(lots)) - - // Import each lot - var imported, skipped, updated int - for _, lot := range lots { - category, model := ParsePartNumber(lot.LotName) - - var categoryID *uint - if id, ok := categoryMap[category]; ok && id > 0 { - categoryID = &id - } else { - // Try to find by prefix match - for code, id := range categoryMap { - if strings.HasPrefix(category, code) { - categoryID = &id - break - } - } - } - - // Check if already exists - var existing models.LotMetadata - result := db.Where("lot_name = ?", lot.LotName).First(&existing) - - if result.Error == gorm.ErrRecordNotFound { - // Check if there are prices in the last 90 days - var recentPriceCount int64 - db.Model(&models.LotLog{}). - Where("lot = ? AND date >= DATE_SUB(NOW(), INTERVAL 90 DAY)", lot.LotName). - Count(&recentPriceCount) - - // Default to 90 days, but use "all time" (0) if no recent prices - periodDays := 90 - if recentPriceCount == 0 { - periodDays = 0 - } - - // Create new - metadata := models.LotMetadata{ - LotName: lot.LotName, - CategoryID: categoryID, - Model: model, - PricePeriodDays: periodDays, - } - if err := db.Create(&metadata).Error; err != nil { - log.Printf("Failed to create metadata for %s: %v", lot.LotName, err) - continue - } - imported++ - } else if result.Error == nil { - // Update if needed - needsUpdate := false - - if existing.Model == "" { - existing.Model = model - needsUpdate = true - } - if existing.CategoryID == nil { - existing.CategoryID = categoryID - needsUpdate = true - } - - // Check if using default period (90 days) but no recent prices - if existing.PricePeriodDays == 90 { - var recentPriceCount int64 - db.Model(&models.LotLog{}). - Where("lot = ? AND date >= DATE_SUB(NOW(), INTERVAL 90 DAY)", lot.LotName). - Count(&recentPriceCount) - - if recentPriceCount == 0 { - existing.PricePeriodDays = 0 - needsUpdate = true - } - } - - if needsUpdate { - db.Save(&existing) - updated++ - } else { - skipped++ - } - } - } - - log.Printf("Import complete: %d imported, %d updated, %d skipped", imported, updated, skipped) - - // Show final counts - var metadataCount int64 - db.Model(&models.LotMetadata{}).Count(&metadataCount) - log.Printf("Total metadata records: %d", metadataCount) -} - -// ParsePartNumber extracts category and model from lot_name -// Examples: -// "CPU_AMD_9654" → category="CPU", model="AMD_9654" -// "MB_INTEL_4.Sapphire_2S" → category="MB", model="INTEL_4.Sapphire_2S" -func ParsePartNumber(lotName string) (category, model string) { - parts := strings.SplitN(lotName, "_", 2) - if len(parts) >= 1 { - category = parts[0] - } - if len(parts) >= 2 { - model = parts[1] - } - return -} diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go index d113d40..6eaadc0 100644 --- a/cmd/qfs/main.go +++ b/cmd/qfs/main.go @@ -17,6 +17,7 @@ import ( "sort" "strconv" "strings" + syncpkg "sync" "syscall" "time" @@ -31,9 +32,6 @@ import ( "git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/repository" "git.mchus.pro/mchus/quoteforge/internal/services" - "git.mchus.pro/mchus/quoteforge/internal/services/alerts" - "git.mchus.pro/mchus/quoteforge/internal/services/pricelist" - "git.mchus.pro/mchus/quoteforge/internal/services/pricing" "git.mchus.pro/mchus/quoteforge/internal/services/sync" "github.com/gin-gonic/gin" "gorm.io/driver/mysql" @@ -45,6 +43,7 @@ import ( var Version = "dev" const backgroundSyncInterval = 5 * time.Minute +const onDemandPullCooldown = 30 * time.Second func main() { configPath := flag.String("config", "", "path to config file (default: user state dir or QFS_CONFIG_PATH)") @@ -207,6 +206,15 @@ func main() { os.Exit(1) } + if readiness, readinessErr := syncService.GetReadiness(); readinessErr != nil { + slog.Warn("sync readiness check failed on startup", "error", readinessErr) + } else if readiness != nil && readiness.Blocked { + slog.Warn("sync readiness blocked on startup", + "reason_code", readiness.ReasonCode, + "reason_text", readiness.ReasonText, + ) + } + // Start background sync worker (will auto-skip when offline) workerCtx, workerCancel := context.WithCancel(context.Background()) defer workerCancel() @@ -446,8 +454,6 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect // Repositories var componentRepo *repository.ComponentRepository var categoryRepo *repository.CategoryRepository - var priceRepo *repository.PriceRepository - var alertRepo *repository.AlertRepository var statsRepo *repository.StatsRepository var pricelistRepo *repository.PricelistRepository @@ -455,8 +461,6 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect if mariaDB != nil { componentRepo = repository.NewComponentRepository(mariaDB) categoryRepo = repository.NewCategoryRepository(mariaDB) - priceRepo = repository.NewPriceRepository(mariaDB) - alertRepo = repository.NewAlertRepository(mariaDB) statsRepo = repository.NewStatsRepository(mariaDB) pricelistRepo = repository.NewPricelistRepository(mariaDB) } else { @@ -465,13 +469,9 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect } // Services - var pricingService *pricing.Service var componentService *services.ComponentService var quoteService *services.QuoteService var exportService *services.ExportService - var alertService *alerts.Service - var pricelistService *pricelist.Service - var stockImportService *services.StockImportService var syncService *sync.Service var projectService *services.ProjectService @@ -479,22 +479,14 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect syncService = sync.NewService(connMgr, local) if mariaDB != nil { - pricingService = pricing.NewService(componentRepo, priceRepo, cfg.Pricing) componentService = services.NewComponentService(componentRepo, categoryRepo, statsRepo) - quoteService = services.NewQuoteService(componentRepo, statsRepo, pricelistRepo, local, pricingService) + quoteService = services.NewQuoteService(componentRepo, statsRepo, pricelistRepo, local, nil) exportService = services.NewExportService(cfg.Export, categoryRepo) - alertService = alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing) - pricelistService = pricelist.NewService(mariaDB, pricelistRepo, componentRepo, pricingService) - stockImportService = services.NewStockImportService(mariaDB, pricelistService) } else { - // In offline mode, we still need to create services that don't require DB - pricingService = pricing.NewService(nil, nil, cfg.Pricing) + // In offline mode, we still need to create services that don't require DB. componentService = services.NewComponentService(nil, nil, nil) - quoteService = services.NewQuoteService(nil, nil, nil, local, pricingService) + quoteService = services.NewQuoteService(nil, nil, nil, local, nil) exportService = services.NewExportService(cfg.Export, nil) - alertService = alerts.NewService(nil, nil, nil, nil, cfg.Alerts, cfg.Pricing) - pricelistService = pricelist.NewService(nil, nil, nil, nil) - stockImportService = nil } // isOnline function for local-first architecture @@ -526,20 +518,75 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect } } - syncProjectsFromServer := func() { - if !connMgr.IsOnline() { + type pullState struct { + mu syncpkg.Mutex + running bool + lastStarted time.Time + } + triggerPull := func(label string, state *pullState, pullFn func() error) { + state.mu.Lock() + if state.running { + state.mu.Unlock() return } - if _, err := syncService.ImportProjectsToLocal(); err != nil && !errors.Is(err, sync.ErrOffline) { - slog.Warn("failed to sync projects from server", "error", err) + if !state.lastStarted.IsZero() && time.Since(state.lastStarted) < onDemandPullCooldown { + state.mu.Unlock() + return } + state.running = true + state.lastStarted = time.Now() + state.mu.Unlock() + + go func() { + defer func() { + state.mu.Lock() + state.running = false + state.mu.Unlock() + }() + if err := pullFn(); err != nil { + slog.Warn("on-demand pull failed", "scope", label, "error", err) + } + }() } - syncConfigurationsFromServer := func() { + var projectsPullState pullState + var configsPullState pullState + + syncProjectsFromServer := func() error { if !connMgr.IsOnline() { - return + return nil } - _, _ = configService.ImportFromServer() + if readiness, err := syncService.EnsureReadinessForSync(); err != nil { + slog.Warn("skipping project pull: sync readiness blocked", + "error", err, + "reason_code", readiness.ReasonCode, + "reason_text", readiness.ReasonText, + ) + return nil + } + if _, err := syncService.ImportProjectsToLocal(); err != nil && !errors.Is(err, sync.ErrOffline) { + return err + } + return nil + } + + syncConfigurationsFromServer := func() error { + if !connMgr.IsOnline() { + return nil + } + if readiness, err := syncService.EnsureReadinessForSync(); err != nil { + slog.Warn("skipping configuration pull: sync readiness blocked", + "error", err, + "reason_code", readiness.ReasonCode, + "reason_text", readiness.ReasonText, + ) + return nil + } + _, err := configService.ImportFromServer() + if err != nil && !errors.Is(err, sync.ErrOffline) { + return err + } + return nil } // Use filepath.Join for cross-platform path compatibility @@ -549,17 +596,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect componentHandler := handlers.NewComponentHandler(componentService, local) quoteHandler := handlers.NewQuoteHandler(quoteService) exportHandler := handlers.NewExportHandler(exportService, configService, componentService) - pricingHandler := handlers.NewPricingHandler( - mariaDB, - pricingService, - alertService, - componentRepo, - priceRepo, - statsRepo, - stockImportService, - local.GetDBUser(), - ) - pricelistHandler := handlers.NewPricelistHandler(pricelistService, local) + pricelistHandler := handlers.NewPricelistHandler(local) syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval) if err != nil { return nil, nil, fmt.Errorf("creating sync handler: %w", err) @@ -615,28 +652,39 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect // DB status endpoint router.GET("/api/db-status", func(c *gin.Context) { var lotCount, lotLogCount, metadataCount int64 - var dbOK bool = false + var dbOK bool var dbError string + includeCounts := c.Query("include_counts") == "true" - // Check if connection exists (fast check, no reconnect attempt) + // Fast status path: do not execute heavy COUNT queries unless requested. status := connMgr.GetStatus() - if status.IsConnected { - // Already connected, safe to use - if db, err := connMgr.GetDB(); err == nil && db != nil { - dbOK = true - db.Table("lot").Count(&lotCount) - db.Table("lot_log").Count(&lotLogCount) - db.Table("qt_lot_metadata").Count(&metadataCount) - } - } else { - // Not connected - don't try to reconnect on status check - // This prevents 3s timeout on every request + dbOK = status.IsConnected + if !status.IsConnected { dbError = "Database not connected (offline mode)" if status.LastError != "" { dbError = status.LastError } } + // Optional diagnostics mode with server table counts. + if includeCounts && status.IsConnected { + if db, err := connMgr.GetDB(); err == nil && db != nil { + _ = db.Table("lot").Count(&lotCount) + _ = db.Table("lot_log").Count(&lotLogCount) + _ = db.Table("qt_lot_metadata").Count(&metadataCount) + } else if err != nil { + dbOK = false + dbError = err.Error() + } else { + dbOK = false + dbError = "Database not connected (offline mode)" + } + } else { + lotCount = 0 + lotLogCount = 0 + metadataCount = 0 + } + c.JSON(http.StatusOK, gin.H{ "connected": dbOK, "error": dbError, @@ -667,12 +715,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect router.GET("/configurator", webHandler.Configurator) router.GET("/projects", webHandler.Projects) router.GET("/projects/:uuid", webHandler.ProjectDetail) - router.GET("/pricelists", func(c *gin.Context) { - // Redirect to admin/pricing with pricelists tab - c.Redirect(http.StatusFound, "/admin/pricing?tab=pricelists") - }) + router.GET("/pricelists", webHandler.Pricelists) router.GET("/pricelists/:id", webHandler.PricelistDetail) - router.GET("/admin/pricing", webHandler.AdminPricing) // htmx partials partials := router.Group("/partials") @@ -716,22 +760,17 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect pricelists := api.Group("/pricelists") { pricelists.GET("", pricelistHandler.List) - pricelists.GET("/can-write", pricelistHandler.CanWrite) pricelists.GET("/latest", pricelistHandler.GetLatest) pricelists.GET("/:id", pricelistHandler.Get) pricelists.GET("/:id/items", pricelistHandler.GetItems) pricelists.GET("/:id/lots", pricelistHandler.GetLotNames) - pricelists.POST("", pricelistHandler.Create) - pricelists.POST("/create-with-progress", pricelistHandler.CreateWithProgress) - pricelists.PATCH("/:id/active", pricelistHandler.SetActive) - pricelists.DELETE("/:id", pricelistHandler.Delete) } // Configurations (public - RBAC disabled) configs := api.Group("/configs") { configs.GET("", func(c *gin.Context) { - syncConfigurationsFromServer() + triggerPull("configs", &configsPullState, syncConfigurationsFromServer) page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20")) @@ -1018,8 +1057,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect projects := api.Group("/projects") { projects.GET("", func(c *gin.Context) { - syncProjectsFromServer() - syncConfigurationsFromServer() + triggerPull("projects", &projectsPullState, syncProjectsFromServer) + triggerPull("configs", &configsPullState, syncConfigurationsFromServer) status := c.DefaultQuery("status", "active") search := strings.ToLower(strings.TrimSpace(c.Query("search"))) @@ -1128,17 +1167,26 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect paged = filtered[start:end] } + // Build per-project active config stats in one pass (avoid N+1 scans). + projectConfigCount := map[string]int{} + projectConfigTotal := map[string]float64{} + if localConfigs, cfgErr := local.GetConfigurations(); cfgErr == nil { + for i := range localConfigs { + cfg := localConfigs[i] + if !cfg.IsActive || cfg.ProjectUUID == nil || *cfg.ProjectUUID == "" { + continue + } + projectUUID := *cfg.ProjectUUID + projectConfigCount[projectUUID]++ + if cfg.TotalPrice != nil { + projectConfigTotal[projectUUID] += *cfg.TotalPrice + } + } + } + projectRows := make([]gin.H, 0, len(paged)) for i := range paged { p := paged[i] - configs, err := projectService.ListConfigurations(p.UUID, dbUsername, "active") - if err != nil { - configs = &services.ProjectConfigurationsResult{ - ProjectUUID: p.UUID, - Configs: []models.Configuration{}, - Total: 0, - } - } projectRows = append(projectRows, gin.H{ "id": p.ID, "uuid": p.UUID, @@ -1149,8 +1197,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect "is_system": p.IsSystem, "created_at": p.CreatedAt, "updated_at": p.UpdatedAt, - "config_count": len(configs.Configs), - "total": configs.Total, + "config_count": projectConfigCount[p.UUID], + "total": projectConfigTotal[p.UUID], }) } @@ -1258,7 +1306,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect }) projects.GET("/:uuid/configs", func(c *gin.Context) { - syncConfigurationsFromServer() + triggerPull("configs", &configsPullState, syncConfigurationsFromServer) status := c.DefaultQuery("status", "active") if status != "active" && status != "archived" && status != "all" { @@ -1318,35 +1366,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect }) } - // Pricing admin (public - RBAC disabled) - pricingAdmin := api.Group("/admin/pricing") - { - pricingAdmin.GET("/stats", pricingHandler.GetStats) - pricingAdmin.GET("/components", pricingHandler.ListComponents) - pricingAdmin.GET("/components/:lot_name", pricingHandler.GetComponentPricing) - pricingAdmin.POST("/update", pricingHandler.UpdatePrice) - pricingAdmin.POST("/preview", pricingHandler.PreviewPrice) - pricingAdmin.POST("/recalculate-all", pricingHandler.RecalculateAll) - pricingAdmin.GET("/lots", pricingHandler.ListLots) - pricingAdmin.GET("/lots-table", pricingHandler.ListLotsTable) - pricingAdmin.POST("/stock/import", pricingHandler.ImportStockLog) - pricingAdmin.GET("/stock/mappings", pricingHandler.ListStockMappings) - pricingAdmin.POST("/stock/mappings", pricingHandler.UpsertStockMapping) - pricingAdmin.DELETE("/stock/mappings/:partnumber", pricingHandler.DeleteStockMapping) - pricingAdmin.GET("/stock/ignore-rules", pricingHandler.ListStockIgnoreRules) - pricingAdmin.POST("/stock/ignore-rules", pricingHandler.UpsertStockIgnoreRule) - pricingAdmin.DELETE("/stock/ignore-rules/:id", pricingHandler.DeleteStockIgnoreRule) - - pricingAdmin.GET("/alerts", pricingHandler.ListAlerts) - pricingAdmin.POST("/alerts/:id/acknowledge", pricingHandler.AcknowledgeAlert) - pricingAdmin.POST("/alerts/:id/resolve", pricingHandler.ResolveAlert) - pricingAdmin.POST("/alerts/:id/ignore", pricingHandler.IgnoreAlert) - } - // Sync API (for offline mode) syncAPI := api.Group("/sync") { syncAPI.GET("/status", syncHandler.GetStatus) + syncAPI.GET("/readiness", syncHandler.GetReadiness) syncAPI.GET("/info", syncHandler.GetInfo) syncAPI.GET("/users-status", syncHandler.GetUsersStatus) syncAPI.POST("/components", syncHandler.SyncComponents) diff --git a/internal/handlers/component.go b/internal/handlers/component.go index dc1682c..55166ee 100644 --- a/internal/handlers/component.go +++ b/internal/handlers/component.go @@ -3,8 +3,10 @@ package handlers import ( "net/http" "strconv" + "strings" "git.mchus.pro/mchus/quoteforge/internal/localdb" + "git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/repository" "git.mchus.pro/mchus/quoteforge/internal/services" "github.com/gin-gonic/gin" @@ -25,6 +27,12 @@ func NewComponentHandler(componentService *services.ComponentService, localDB *l func (h *ComponentHandler) List(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20")) + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 20 + } filter := repository.ComponentFilter{ Category: c.Query("category"), @@ -33,73 +41,70 @@ func (h *ComponentHandler) List(c *gin.Context) { ExcludeHidden: c.Query("include_hidden") != "true", // По умолчанию скрытые не показываются } - result, err := h.componentService.List(filter, page, perPage) + localFilter := localdb.ComponentFilter{ + Category: filter.Category, + Search: filter.Search, + HasPrice: filter.HasPrice, + } + offset := (page - 1) * perPage + localComps, total, err := h.localDB.ListComponents(localFilter, offset, perPage) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - // If offline mode (empty result), fallback to local components - isOffline := false - if v, ok := c.Get("is_offline"); ok { - if b, ok := v.(bool); ok { - isOffline = b - } - } - if isOffline && result.Total == 0 && h.localDB != nil { - localFilter := localdb.ComponentFilter{ - Category: filter.Category, - Search: filter.Search, - HasPrice: filter.HasPrice, - } - - offset := (page - 1) * perPage - localComps, total, err := h.localDB.ListComponents(localFilter, offset, perPage) - if err == nil && len(localComps) > 0 { - // Convert local components to ComponentView format - components := make([]services.ComponentView, len(localComps)) - for i, lc := range localComps { - components[i] = services.ComponentView{ - LotName: lc.LotName, - Description: lc.LotDescription, - Category: lc.Category, - CategoryName: lc.Category, // No translation in local mode - Model: lc.Model, - CurrentPrice: lc.CurrentPrice, - } - } - - c.JSON(http.StatusOK, &services.ComponentListResult{ - Components: components, - Total: total, - Page: page, - PerPage: perPage, - }) - return + components := make([]services.ComponentView, len(localComps)) + for i, lc := range localComps { + components[i] = services.ComponentView{ + LotName: lc.LotName, + Description: lc.LotDescription, + Category: lc.Category, + CategoryName: lc.Category, + Model: lc.Model, + CurrentPrice: lc.CurrentPrice, } } - c.JSON(http.StatusOK, result) + c.JSON(http.StatusOK, &services.ComponentListResult{ + Components: components, + Total: total, + Page: page, + PerPage: perPage, + }) } func (h *ComponentHandler) Get(c *gin.Context) { lotName := c.Param("lot_name") - - component, err := h.componentService.GetByLotName(lotName) + component, err := h.localDB.GetLocalComponent(lotName) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "component not found"}) return } - c.JSON(http.StatusOK, component) + c.JSON(http.StatusOK, services.ComponentView{ + LotName: component.LotName, + Description: component.LotDescription, + Category: component.Category, + CategoryName: component.Category, + Model: component.Model, + CurrentPrice: component.CurrentPrice, + }) } func (h *ComponentHandler) GetCategories(c *gin.Context) { - categories, err := h.componentService.GetCategories() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + codes, err := h.localDB.GetLocalComponentCategories() + if err == nil && len(codes) > 0 { + categories := make([]models.Category, 0, len(codes)) + for _, code := range codes { + trimmed := strings.TrimSpace(code) + if trimmed == "" { + continue + } + categories = append(categories, models.Category{Code: trimmed, Name: trimmed}) + } + c.JSON(http.StatusOK, categories) return } - c.JSON(http.StatusOK, categories) + c.JSON(http.StatusOK, models.DefaultCategories) } diff --git a/internal/handlers/pricelist.go b/internal/handlers/pricelist.go index 48a11f6..b220e57 100644 --- a/internal/handlers/pricelist.go +++ b/internal/handlers/pricelist.go @@ -1,121 +1,25 @@ package handlers import ( - "errors" - "fmt" - "io" "net/http" "sort" "strconv" "strings" - "time" "git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/models" - "git.mchus.pro/mchus/quoteforge/internal/services/pricelist" "github.com/gin-gonic/gin" ) type PricelistHandler struct { - service *pricelist.Service localDB *localdb.LocalDB } -func NewPricelistHandler(service *pricelist.Service, localDB *localdb.LocalDB) *PricelistHandler { - return &PricelistHandler{service: service, localDB: localDB} +func NewPricelistHandler(localDB *localdb.LocalDB) *PricelistHandler { + return &PricelistHandler{localDB: localDB} } -// refreshLocalPricelistCacheFromServer rehydrates local metadata + items for one server pricelist. -func (h *PricelistHandler) refreshLocalPricelistCacheFromServer(serverID uint, onProgress func(synced, total int, message string)) error { - if h.localDB == nil { - return nil - } - - report := func(synced, total int, message string) { - if onProgress != nil { - onProgress(synced, total, message) - } - } - report(0, 0, "Подготовка локального кэша") - - pl, err := h.service.GetByID(serverID) - if err != nil { - return err - } - - if existing, err := h.localDB.GetLocalPricelistByServerID(serverID); err == nil { - if err := h.localDB.DeleteLocalPricelist(existing.ID); err != nil { - return err - } - } - - localPL := &localdb.LocalPricelist{ - ServerID: pl.ID, - Source: pl.Source, - Version: pl.Version, - Name: pl.Notification, - CreatedAt: pl.CreatedAt, - SyncedAt: time.Now(), - IsUsed: false, - } - if err := h.localDB.SaveLocalPricelist(localPL); err != nil { - return err - } - report(0, 0, "Локальный кэш обновлён") - // Ensure we use persisted local row id (upsert path may not populate struct ID reliably). - persistedLocalPL, err := h.localDB.GetLocalPricelistByServerID(serverID) - if err != nil { - return err - } - if persistedLocalPL.ID == 0 { - return fmt.Errorf("local pricelist id is zero after save (server_id=%d)", serverID) - } - - const perPage = 2000 - synced := 0 - totalItems := 0 - gotTotal := false - for page := 1; ; page++ { - items, total, err := h.service.GetItems(serverID, page, perPage, "") - if err != nil { - return err - } - if !gotTotal { - totalItems = int(total) - gotTotal = true - } - if len(items) == 0 { - break - } - - localItems := make([]localdb.LocalPricelistItem, 0, len(items)) - for _, item := range items { - partnumbers := make(localdb.LocalStringList, 0, len(item.Partnumbers)) - partnumbers = append(partnumbers, item.Partnumbers...) - localItems = append(localItems, localdb.LocalPricelistItem{ - PricelistID: persistedLocalPL.ID, - LotName: item.LotName, - Price: item.Price, - AvailableQty: item.AvailableQty, - Partnumbers: partnumbers, - }) - } - if err := h.localDB.SaveLocalPricelistItems(localItems); err != nil { - return err - } - synced += len(localItems) - report(synced, totalItems, "Синхронизация позиций в локальный кэш") - - if int64(page*perPage) >= total { - break - } - } - report(synced, totalItems, "Локальный кэш синхронизирован") - - return nil -} - -// List returns all pricelists with pagination +// List returns all pricelists with pagination. func (h *PricelistHandler) List(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20")) @@ -184,7 +88,7 @@ func (h *PricelistHandler) List(c *gin.Context) { }) } -// Get returns a single pricelist by ID +// Get returns a single pricelist by ID. func (h *PricelistHandler) Get(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseUint(idStr, 10, 32) @@ -211,299 +115,7 @@ func (h *PricelistHandler) Get(c *gin.Context) { }) } -// Create creates a new pricelist from current prices -func (h *PricelistHandler) Create(c *gin.Context) { - canWrite, debugInfo := h.service.CanWriteDebug() - if !canWrite { - c.JSON(http.StatusForbidden, gin.H{ - "error": "pricelist write is not allowed", - "debug": debugInfo, - }) - return - } - - var req struct { - Source string `json:"source"` - Items []struct { - LotName string `json:"lot_name"` - Price float64 `json:"price"` - } `json:"items"` - } - if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - source := string(models.NormalizePricelistSource(req.Source)) - - // Get the database username as the creator - createdBy := h.localDB.GetDBUser() - if createdBy == "" { - createdBy = "unknown" - } - sourceItems := make([]pricelist.CreateItemInput, 0, len(req.Items)) - for _, item := range req.Items { - sourceItems = append(sourceItems, pricelist.CreateItemInput{ - LotName: item.LotName, - Price: item.Price, - }) - } - - pl, err := h.service.CreateForSourceWithProgress(createdBy, source, sourceItems, nil) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - // Keep local cache consistent for local-first reads (metadata + items). - if err := h.refreshLocalPricelistCacheFromServer(pl.ID, nil); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "pricelist created on server but failed to refresh local cache: " + err.Error(), - }) - return - } - - c.JSON(http.StatusCreated, pl) -} - -// CreateWithProgress creates a pricelist and streams progress updates over SSE. -func (h *PricelistHandler) CreateWithProgress(c *gin.Context) { - canWrite, debugInfo := h.service.CanWriteDebug() - if !canWrite { - c.JSON(http.StatusForbidden, gin.H{ - "error": "pricelist write is not allowed", - "debug": debugInfo, - }) - return - } - - var req struct { - Source string `json:"source"` - Items []struct { - LotName string `json:"lot_name"` - Price float64 `json:"price"` - } `json:"items"` - } - if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - source := string(models.NormalizePricelistSource(req.Source)) - - createdBy := h.localDB.GetDBUser() - if createdBy == "" { - createdBy = "unknown" - } - sourceItems := make([]pricelist.CreateItemInput, 0, len(req.Items)) - for _, item := range req.Items { - sourceItems = append(sourceItems, pricelist.CreateItemInput{ - LotName: item.LotName, - Price: item.Price, - }) - } - - c.Header("Content-Type", "text/event-stream") - c.Header("Cache-Control", "no-cache") - c.Header("Connection", "keep-alive") - c.Header("X-Accel-Buffering", "no") - - flusher, ok := c.Writer.(http.Flusher) - if !ok { - pl, err := h.service.CreateForSourceWithProgress(createdBy, source, sourceItems, nil) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusCreated, pl) - return - } - - sendProgress := func(payload gin.H) { - c.SSEvent("progress", payload) - flusher.Flush() - } - - sendProgress(gin.H{"current": 0, "total": 4, "status": "starting", "message": "Запуск..."}) - pl, err := h.service.CreateForSourceWithProgress(createdBy, source, sourceItems, func(p pricelist.CreateProgress) { - // Composite progress: 0-85% server creation, 86-99% local cache sync. - current := int(float64(p.Current) * 0.85) - if p.Status == "completed" { - current = 85 - } - status := p.Status - if status == "completed" { - status = "server_completed" - } - sendProgress(gin.H{ - "current": current, - "total": p.Total, - "status": status, - "message": p.Message, - "updated": p.Updated, - "errors": p.Errors, - "lot_name": p.LotName, - }) - }) - if err != nil { - sendProgress(gin.H{ - "current": 0, - "total": 4, - "status": "error", - "message": fmt.Sprintf("Ошибка: %v", err), - }) - return - } - - if err := h.refreshLocalPricelistCacheFromServer(pl.ID, func(synced, total int, message string) { - current := 86 - if total > 0 { - progressPart := int(float64(synced) / float64(total) * 13.0) // 86..99 - if progressPart > 13 { - progressPart = 13 - } - current = 86 + progressPart - } - if current > 99 { - current = 99 - } - sendProgress(gin.H{ - "current": current, - "total": 100, - "status": "sync_local_cache", - "message": message, - }) - }); err != nil { - sendProgress(gin.H{ - "current": 4, - "total": 4, - "status": "error", - "message": fmt.Sprintf("Прайслист создан, но локальный кэш не обновлён: %v", err), - }) - return - } - - sendProgress(gin.H{ - "current": 4, - "total": 4, - "status": "completed", - "message": "Готово", - "pricelist": pl, - }) -} - -// Delete deletes a pricelist by ID -func (h *PricelistHandler) Delete(c *gin.Context) { - canWrite, debugInfo := h.service.CanWriteDebug() - if !canWrite { - c.JSON(http.StatusForbidden, gin.H{ - "error": "pricelist write is not allowed", - "debug": debugInfo, - }) - return - } - - idStr := c.Param("id") - id, err := strconv.ParseUint(idStr, 10, 32) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"}) - return - } - - if err := h.service.Delete(uint(id)); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Local-first UI reads pricelists from SQLite cache. Keep cache in sync right away. - if h.localDB != nil { - if localPL, err := h.localDB.GetLocalPricelistByServerID(uint(id)); err == nil { - if err := h.localDB.DeleteLocalPricelist(localPL.ID); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "pricelist deleted on server but failed to update local cache: " + err.Error(), - }) - return - } - } - } - - c.JSON(http.StatusOK, gin.H{"message": "pricelist deleted"}) -} - -// SetActive toggles active flag on a pricelist. -func (h *PricelistHandler) SetActive(c *gin.Context) { - canWrite, debugInfo := h.service.CanWriteDebug() - if !canWrite { - c.JSON(http.StatusForbidden, gin.H{ - "error": "pricelist write is not allowed", - "debug": debugInfo, - }) - return - } - - idStr := c.Param("id") - id, err := strconv.ParseUint(idStr, 10, 32) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"}) - return - } - - var req struct { - IsActive bool `json:"is_active"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if err := h.service.SetActive(uint(id), req.IsActive); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Local-first table stores only active snapshots. Reflect toggles immediately. - if h.localDB != nil { - localPL, err := h.localDB.GetLocalPricelistByServerID(uint(id)) - if err == nil { - if req.IsActive { - // Ensure local active row has complete cache (metadata + items). - if h.localDB.CountLocalPricelistItems(localPL.ID) == 0 { - if err := h.refreshLocalPricelistCacheFromServer(uint(id), nil); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "updated on server but failed to refresh local cache: " + err.Error(), - }) - return - } - } else { - localPL.SyncedAt = time.Now() - if saveErr := h.localDB.SaveLocalPricelist(localPL); saveErr != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "updated on server but failed to update local cache: " + saveErr.Error(), - }) - return - } - } - } else { - // Inactive entries should disappear from local active cache list. - if delErr := h.localDB.DeleteLocalPricelist(localPL.ID); delErr != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "updated on server but failed to update local cache: " + delErr.Error(), - }) - return - } - } - } else if req.IsActive { - if err := h.refreshLocalPricelistCacheFromServer(uint(id), nil); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "updated on server but failed to seed local cache: " + err.Error(), - }) - return - } - } - } - - c.JSON(http.StatusOK, gin.H{"message": "updated", "is_active": req.IsActive}) -} - -// GetItems returns items for a pricelist with pagination +// GetItems returns items for a pricelist with pagination. func (h *PricelistHandler) GetItems(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseUint(idStr, 10, 32) @@ -598,13 +210,7 @@ func (h *PricelistHandler) GetLotNames(c *gin.Context) { }) } -// CanWrite returns whether the current user can create pricelists -func (h *PricelistHandler) CanWrite(c *gin.Context) { - canWrite, debugInfo := h.service.CanWriteDebug() - c.JSON(http.StatusOK, gin.H{"can_write": canWrite, "debug": debugInfo}) -} - -// GetLatest returns the most recent active pricelist +// GetLatest returns the most recent active pricelist. func (h *PricelistHandler) GetLatest(c *gin.Context) { source := c.DefaultQuery("source", string(models.PricelistSourceEstimate)) source = string(models.NormalizePricelistSource(source)) diff --git a/internal/handlers/pricing.go b/internal/handlers/pricing.go deleted file mode 100644 index c59e203..0000000 --- a/internal/handlers/pricing.go +++ /dev/null @@ -1,1420 +0,0 @@ -package handlers - -import ( - "io" - "net/http" - "os" - "sort" - "strconv" - "strings" - "time" - - "git.mchus.pro/mchus/quoteforge/internal/models" - "git.mchus.pro/mchus/quoteforge/internal/repository" - "git.mchus.pro/mchus/quoteforge/internal/services" - "git.mchus.pro/mchus/quoteforge/internal/services/alerts" - "git.mchus.pro/mchus/quoteforge/internal/services/pricing" - "git.mchus.pro/mchus/quoteforge/internal/warehouse" - "github.com/gin-gonic/gin" - "gorm.io/gorm" -) - -// calculateMedian returns the median of a sorted slice of prices -func calculateMedian(prices []float64) float64 { - if len(prices) == 0 { - return 0 - } - sort.Float64s(prices) - n := len(prices) - if n%2 == 0 { - return (prices[n/2-1] + prices[n/2]) / 2 - } - return prices[n/2] -} - -// calculateAverage returns the arithmetic mean of prices -func calculateAverage(prices []float64) float64 { - if len(prices) == 0 { - return 0 - } - var sum float64 - for _, p := range prices { - sum += p - } - return sum / float64(len(prices)) -} - -type PricingHandler struct { - db *gorm.DB - pricingService *pricing.Service - alertService *alerts.Service - componentRepo *repository.ComponentRepository - priceRepo *repository.PriceRepository - statsRepo *repository.StatsRepository - stockImportService *services.StockImportService - dbUsername string -} - -func NewPricingHandler( - db *gorm.DB, - pricingService *pricing.Service, - alertService *alerts.Service, - componentRepo *repository.ComponentRepository, - priceRepo *repository.PriceRepository, - statsRepo *repository.StatsRepository, - stockImportService *services.StockImportService, - dbUsername string, -) *PricingHandler { - return &PricingHandler{ - db: db, - pricingService: pricingService, - alertService: alertService, - componentRepo: componentRepo, - priceRepo: priceRepo, - statsRepo: statsRepo, - stockImportService: stockImportService, - dbUsername: dbUsername, - } -} - -func (h *PricingHandler) GetStats(c *gin.Context) { - // Check if we're in offline mode - if h.statsRepo == nil || h.alertService == nil { - c.JSON(http.StatusOK, gin.H{ - "new_alerts_count": 0, - "top_components": []interface{}{}, - "trending_components": []interface{}{}, - "offline": true, - }) - return - } - - newAlerts, _ := h.alertService.GetNewAlertsCount() - topComponents, _ := h.statsRepo.GetTopComponents(10) - trendingComponents, _ := h.statsRepo.GetTrendingComponents(10) - - c.JSON(http.StatusOK, gin.H{ - "new_alerts_count": newAlerts, - "top_components": topComponents, - "trending_components": trendingComponents, - }) -} - -type ComponentWithCount struct { - models.LotMetadata - QuoteCount int64 `json:"quote_count"` - UsedInMeta []string `json:"used_in_meta,omitempty"` // List of meta-articles that use this component -} - -func (h *PricingHandler) ListComponents(c *gin.Context) { - // Check if we're in offline mode - if h.componentRepo == nil { - c.JSON(http.StatusOK, gin.H{ - "components": []ComponentWithCount{}, - "total": 0, - "page": 1, - "per_page": 20, - "offline": true, - "message": "Управление ценами доступно только в онлайн режиме", - }) - return - } - - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20")) - - filter := repository.ComponentFilter{ - Category: c.Query("category"), - Search: c.Query("search"), - SortField: c.Query("sort"), - SortDir: c.Query("dir"), - } - - if page < 1 { - page = 1 - } - if perPage < 1 || perPage > 100 { - perPage = 20 - } - offset := (page - 1) * perPage - - components, total, err := h.componentRepo.List(filter, offset, perPage) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - // Get quote counts - lotNames := make([]string, len(components)) - for i, comp := range components { - lotNames[i] = comp.LotName - } - - counts, _ := h.priceRepo.GetQuoteCounts(lotNames) - - // Get meta usage information - metaUsage := h.getMetaUsageMap(lotNames) - - // Combine components with counts - result := make([]ComponentWithCount, len(components)) - for i, comp := range components { - result[i] = ComponentWithCount{ - LotMetadata: comp, - QuoteCount: counts[comp.LotName], - UsedInMeta: metaUsage[comp.LotName], - } - } - - c.JSON(http.StatusOK, gin.H{ - "components": result, - "total": total, - "page": page, - "per_page": perPage, - }) -} - -// getMetaUsageMap returns a map of lot_name -> list of meta-articles that use this component -func (h *PricingHandler) getMetaUsageMap(lotNames []string) map[string][]string { - result := make(map[string][]string) - - // Get all components with meta_prices - var metaComponents []models.LotMetadata - h.db.Where("meta_prices IS NOT NULL AND meta_prices != ''").Find(&metaComponents) - - // Build reverse lookup: which components are used in which meta-articles - for _, meta := range metaComponents { - sources := strings.Split(meta.MetaPrices, ",") - for _, source := range sources { - source = strings.TrimSpace(source) - if source == "" { - continue - } - - // Handle wildcard patterns - if strings.HasSuffix(source, "*") { - prefix := strings.TrimSuffix(source, "*") - for _, lotName := range lotNames { - if strings.HasPrefix(lotName, prefix) && lotName != meta.LotName { - result[lotName] = append(result[lotName], meta.LotName) - } - } - } else { - // Direct match - for _, lotName := range lotNames { - if lotName == source && lotName != meta.LotName { - result[lotName] = append(result[lotName], meta.LotName) - } - } - } - } - } - - return result -} - -// expandMetaPrices expands meta_prices string to list of actual lot names -func (h *PricingHandler) expandMetaPrices(metaPrices, excludeLot string) []string { - sources := strings.Split(metaPrices, ",") - var result []string - seen := make(map[string]bool) - - for _, source := range sources { - source = strings.TrimSpace(source) - if source == "" { - continue - } - - if strings.HasSuffix(source, "*") { - // Wildcard pattern - find matching lots - prefix := strings.TrimSuffix(source, "*") - var matchingLots []string - h.db.Model(&models.LotMetadata{}). - Where("lot_name LIKE ? AND lot_name != ?", prefix+"%", excludeLot). - Pluck("lot_name", &matchingLots) - for _, lot := range matchingLots { - if !seen[lot] { - result = append(result, lot) - seen[lot] = true - } - } - } else if source != excludeLot && !seen[source] { - result = append(result, source) - seen[source] = true - } - } - - return result -} - -func (h *PricingHandler) GetComponentPricing(c *gin.Context) { - // Check if we're in offline mode - if h.componentRepo == nil || h.pricingService == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{ - "error": "Управление ценами доступно только в онлайн режиме", - "offline": true, - }) - return - } - - lotName := c.Param("lot_name") - - component, err := h.componentRepo.GetByLotName(lotName) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "component not found"}) - return - } - - stats, err := h.pricingService.GetPriceStats(lotName, 0) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "component": component, - "price_stats": stats, - }) -} - -type UpdatePriceRequest struct { - LotName string `json:"lot_name" binding:"required"` - Method models.PriceMethod `json:"method"` - PeriodDays int `json:"period_days"` - Coefficient float64 `json:"coefficient"` - ManualPrice *float64 `json:"manual_price"` - ClearManual bool `json:"clear_manual"` - MetaEnabled bool `json:"meta_enabled"` - MetaPrices string `json:"meta_prices"` - MetaMethod string `json:"meta_method"` - MetaPeriod int `json:"meta_period"` - IsHidden bool `json:"is_hidden"` -} - -func (h *PricingHandler) UpdatePrice(c *gin.Context) { - // Check if we're in offline mode - if h.db == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{ - "error": "Обновление цен доступно только в онлайн режиме", - "offline": true, - }) - return - } - - var req UpdatePriceRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - updates := map[string]interface{}{} - - // Update method if specified - if req.Method != "" { - updates["price_method"] = req.Method - } - - // Update period days - if req.PeriodDays >= 0 { - updates["price_period_days"] = req.PeriodDays - } - - // Update coefficient - updates["price_coefficient"] = req.Coefficient - - // Handle meta prices - if req.MetaEnabled && req.MetaPrices != "" { - updates["meta_prices"] = req.MetaPrices - } else { - updates["meta_prices"] = "" - } - - // Handle hidden flag - updates["is_hidden"] = req.IsHidden - - // Handle manual price - if req.ClearManual { - updates["manual_price"] = nil - } else if req.ManualPrice != nil { - updates["manual_price"] = *req.ManualPrice - // Also update current price immediately when setting manual - updates["current_price"] = *req.ManualPrice - updates["price_updated_at"] = time.Now() - } - - err := h.db.Model(&models.LotMetadata{}). - Where("lot_name = ?", req.LotName). - Updates(updates).Error - - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - // Recalculate price if not using manual price - if req.ManualPrice == nil { - h.recalculateSinglePrice(req.LotName) - } - - // Get updated component to return new price - var comp models.LotMetadata - h.db.Where("lot_name = ?", req.LotName).First(&comp) - - c.JSON(http.StatusOK, gin.H{ - "message": "price updated", - "current_price": comp.CurrentPrice, - }) -} - -func (h *PricingHandler) recalculateSinglePrice(lotName string) { - var comp models.LotMetadata - if err := h.db.Where("lot_name = ?", lotName).First(&comp).Error; err != nil { - return - } - - // Skip if manual price is set - if comp.ManualPrice != nil && *comp.ManualPrice > 0 { - return - } - - periodDays := comp.PricePeriodDays - method := comp.PriceMethod - if method == "" { - method = models.PriceMethodMedian - } - - // Determine which lot names to use for price calculation - lotNames := []string{lotName} - if comp.MetaPrices != "" { - lotNames = h.expandMetaPrices(comp.MetaPrices, lotName) - } - - // Get prices based on period from all relevant lots - var prices []float64 - for _, ln := range lotNames { - var lotPrices []float64 - if strings.HasSuffix(ln, "*") { - pattern := strings.TrimSuffix(ln, "*") + "%" - if periodDays > 0 { - h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`, - pattern, periodDays).Pluck("price", &lotPrices) - } else { - h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? ORDER BY price`, pattern).Pluck("price", &lotPrices) - } - } else { - if periodDays > 0 { - h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`, - ln, periodDays).Pluck("price", &lotPrices) - } else { - h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, ln).Pluck("price", &lotPrices) - } - } - prices = append(prices, lotPrices...) - } - - // If no prices in period, try all time - if len(prices) == 0 && periodDays > 0 { - for _, ln := range lotNames { - var lotPrices []float64 - if strings.HasSuffix(ln, "*") { - pattern := strings.TrimSuffix(ln, "*") + "%" - h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? ORDER BY price`, pattern).Pluck("price", &lotPrices) - } else { - h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, ln).Pluck("price", &lotPrices) - } - prices = append(prices, lotPrices...) - } - } - - if len(prices) == 0 { - return - } - - // Calculate price based on method - sortFloat64s(prices) - var finalPrice float64 - switch method { - case models.PriceMethodMedian: - finalPrice = calculateMedian(prices) - case models.PriceMethodAverage: - finalPrice = calculateAverage(prices) - default: - finalPrice = calculateMedian(prices) - } - - if finalPrice <= 0 { - return - } - - // Apply coefficient - if comp.PriceCoefficient != 0 { - finalPrice = finalPrice * (1 + comp.PriceCoefficient/100) - } - - now := time.Now() - // Only update price, preserve all user settings - h.db.Model(&models.LotMetadata{}). - Where("lot_name = ?", lotName). - Updates(map[string]interface{}{ - "current_price": finalPrice, - "price_updated_at": now, - }) -} - -func (h *PricingHandler) RecalculateAll(c *gin.Context) { - // Check if we're in offline mode - if h.db == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{ - "error": "Пересчёт цен доступен только в онлайн режиме", - "offline": true, - }) - return - } - - // Set headers for SSE - c.Header("Content-Type", "text/event-stream") - c.Header("Cache-Control", "no-cache") - c.Header("Connection", "keep-alive") - - // Get all components with their settings - var components []models.LotMetadata - h.db.Find(&components) - total := int64(len(components)) - - // Pre-load all lot names for efficient wildcard matching - var allLotNames []string - h.db.Model(&models.LotMetadata{}).Pluck("lot_name", &allLotNames) - lotNameSet := make(map[string]bool, len(allLotNames)) - for _, ln := range allLotNames { - lotNameSet[ln] = true - } - - // Pre-load latest quote dates for all lots (for checking updates) - type LotDate struct { - Lot string - Date time.Time - } - var latestDates []LotDate - h.db.Raw(`SELECT lot, MAX(date) as date FROM lot_log GROUP BY lot`).Scan(&latestDates) - lotLatestDate := make(map[string]time.Time, len(latestDates)) - for _, ld := range latestDates { - lotLatestDate[ld.Lot] = ld.Date - } - - // Send initial progress - c.SSEvent("progress", gin.H{"current": 0, "total": total, "status": "starting"}) - c.Writer.Flush() - - // Process components individually to respect their settings - var updated, skipped, manual, unchanged, errors int - now := time.Now() - progressCounter := 0 - - for _, comp := range components { - progressCounter++ - - // If manual price is set, skip recalculation - if comp.ManualPrice != nil && *comp.ManualPrice > 0 { - manual++ - goto sendProgress - } - - // Calculate price based on component's individual settings - { - periodDays := comp.PricePeriodDays - method := comp.PriceMethod - if method == "" { - method = models.PriceMethodMedian - } - - // Determine source lots for price calculation (using cached lot names) - var sourceLots []string - if comp.MetaPrices != "" { - sourceLots = expandMetaPricesWithCache(comp.MetaPrices, comp.LotName, allLotNames) - } else { - sourceLots = []string{comp.LotName} - } - - if len(sourceLots) == 0 { - skipped++ - goto sendProgress - } - - // Check if there are new quotes since last update (using cached dates) - if comp.PriceUpdatedAt != nil { - hasNewData := false - for _, lot := range sourceLots { - if latestDate, ok := lotLatestDate[lot]; ok { - if latestDate.After(*comp.PriceUpdatedAt) { - hasNewData = true - break - } - } - } - if !hasNewData { - unchanged++ - goto sendProgress - } - } - - // Get prices from source lots - var prices []float64 - if periodDays > 0 { - h.db.Raw(`SELECT price FROM lot_log WHERE lot IN ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`, - sourceLots, periodDays).Pluck("price", &prices) - } else { - h.db.Raw(`SELECT price FROM lot_log WHERE lot IN ? ORDER BY price`, - sourceLots).Pluck("price", &prices) - } - - // If no prices in period, try all time - if len(prices) == 0 && periodDays > 0 { - h.db.Raw(`SELECT price FROM lot_log WHERE lot IN ? ORDER BY price`, sourceLots).Pluck("price", &prices) - } - - if len(prices) == 0 { - skipped++ - goto sendProgress - } - - // Calculate price based on method - var basePrice float64 - switch method { - case models.PriceMethodMedian: - basePrice = calculateMedian(prices) - case models.PriceMethodAverage: - basePrice = calculateAverage(prices) - default: - basePrice = calculateMedian(prices) - } - - if basePrice <= 0 { - skipped++ - goto sendProgress - } - - finalPrice := basePrice - - // Apply coefficient - if comp.PriceCoefficient != 0 { - finalPrice = finalPrice * (1 + comp.PriceCoefficient/100) - } - - // Update only price fields - err := h.db.Model(&models.LotMetadata{}). - Where("lot_name = ?", comp.LotName). - Updates(map[string]interface{}{ - "current_price": finalPrice, - "price_updated_at": now, - }).Error - if err != nil { - errors++ - } else { - updated++ - } - } - - sendProgress: - // Send progress update every 10 components to reduce overhead - if progressCounter%10 == 0 || progressCounter == int(total) { - c.SSEvent("progress", gin.H{ - "current": updated + skipped + manual + unchanged + errors, - "total": total, - "updated": updated, - "skipped": skipped, - "manual": manual, - "unchanged": unchanged, - "errors": errors, - "status": "processing", - "lot_name": comp.LotName, - }) - c.Writer.Flush() - } - } - - // Update popularity scores - h.statsRepo.UpdatePopularityScores() - - // Send completion - c.SSEvent("progress", gin.H{ - "current": updated + skipped + manual + unchanged + errors, - "total": total, - "updated": updated, - "skipped": skipped, - "manual": manual, - "unchanged": unchanged, - "errors": errors, - "status": "completed", - }) - c.Writer.Flush() -} - -func (h *PricingHandler) ListAlerts(c *gin.Context) { - // Check if we're in offline mode - if h.db == nil { - c.JSON(http.StatusOK, gin.H{ - "alerts": []interface{}{}, - "total": 0, - "page": 1, - "per_page": 20, - "offline": true, - }) - return - } - - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20")) - - filter := repository.AlertFilter{ - Status: models.AlertStatus(c.Query("status")), - Severity: models.AlertSeverity(c.Query("severity")), - Type: models.AlertType(c.Query("type")), - LotName: c.Query("lot_name"), - } - - alertsList, total, err := h.alertService.List(filter, page, perPage) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "alerts": alertsList, - "total": total, - "page": page, - "per_page": perPage, - }) -} - -func (h *PricingHandler) AcknowledgeAlert(c *gin.Context) { - // Check if we're in offline mode - if h.db == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{ - "error": "Управление алертами доступно только в онлайн режиме", - "offline": true, - }) - return - } - - id, err := strconv.ParseUint(c.Param("id"), 10, 32) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"}) - return - } - - if err := h.alertService.Acknowledge(uint(id)); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "acknowledged"}) -} - -func (h *PricingHandler) ResolveAlert(c *gin.Context) { - // Check if we're in offline mode - if h.db == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{ - "error": "Управление алертами доступно только в онлайн режиме", - "offline": true, - }) - return - } - - id, err := strconv.ParseUint(c.Param("id"), 10, 32) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"}) - return - } - - if err := h.alertService.Resolve(uint(id)); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "resolved"}) -} - -func (h *PricingHandler) IgnoreAlert(c *gin.Context) { - // Check if we're in offline mode - if h.db == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{ - "error": "Управление алертами доступно только в онлайн режиме", - "offline": true, - }) - return - } - - id, err := strconv.ParseUint(c.Param("id"), 10, 32) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"}) - return - } - - if err := h.alertService.Ignore(uint(id)); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "ignored"}) -} - -type PreviewPriceRequest struct { - LotName string `json:"lot_name" binding:"required"` - Method string `json:"method"` - PeriodDays int `json:"period_days"` - Coefficient float64 `json:"coefficient"` - MetaEnabled bool `json:"meta_enabled"` - MetaPrices string `json:"meta_prices"` -} - -func (h *PricingHandler) PreviewPrice(c *gin.Context) { - // Check if we're in offline mode - if h.db == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{ - "error": "Предпросмотр цены доступен только в онлайн режиме", - "offline": true, - }) - return - } - - var req PreviewPriceRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Get component - var comp models.LotMetadata - if err := h.db.Where("lot_name = ?", req.LotName).First(&comp).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "component not found"}) - return - } - - // Determine which lot names to use for price calculation - lotNames := []string{req.LotName} - if req.MetaEnabled && req.MetaPrices != "" { - lotNames = h.expandMetaPrices(req.MetaPrices, req.LotName) - } - - // Get all prices for calculations (from all relevant lots) - var allPrices []float64 - for _, lotName := range lotNames { - var lotPrices []float64 - if strings.HasSuffix(lotName, "*") { - // Wildcard pattern - pattern := strings.TrimSuffix(lotName, "*") + "%" - h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? ORDER BY price`, pattern).Pluck("price", &lotPrices) - } else { - h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, lotName).Pluck("price", &lotPrices) - } - allPrices = append(allPrices, lotPrices...) - } - - // Calculate median for all time - var medianAllTime *float64 - if len(allPrices) > 0 { - sortFloat64s(allPrices) - median := calculateMedian(allPrices) - medianAllTime = &median - } - - // Get quote count (from all relevant lots) - total count - var quoteCountTotal int64 - for _, lotName := range lotNames { - var count int64 - if strings.HasSuffix(lotName, "*") { - pattern := strings.TrimSuffix(lotName, "*") + "%" - h.db.Model(&models.LotLog{}).Where("lot LIKE ?", pattern).Count(&count) - } else { - h.db.Model(&models.LotLog{}).Where("lot = ?", lotName).Count(&count) - } - quoteCountTotal += count - } - - // Get quote count for specified period (if period is > 0) - var quoteCountPeriod int64 - if req.PeriodDays > 0 { - for _, lotName := range lotNames { - var count int64 - if strings.HasSuffix(lotName, "*") { - pattern := strings.TrimSuffix(lotName, "*") + "%" - h.db.Raw(`SELECT COUNT(*) FROM lot_log WHERE lot LIKE ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY)`, pattern, req.PeriodDays).Scan(&count) - } else { - h.db.Raw(`SELECT COUNT(*) FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY)`, lotName, req.PeriodDays).Scan(&count) - } - quoteCountPeriod += count - } - } else { - // If no period specified, period count equals total count - quoteCountPeriod = quoteCountTotal - } - - // Get last received price (from the main lot only) - var lastPrice struct { - Price *float64 - Date *time.Time - } - h.db.Raw(`SELECT price, date FROM lot_log WHERE lot = ? ORDER BY date DESC, lot_log_id DESC LIMIT 1`, req.LotName).Scan(&lastPrice) - - // Calculate new price based on parameters (method, period, coefficient) - method := req.Method - if method == "" { - method = "median" - } - - var prices []float64 - if req.PeriodDays > 0 { - for _, lotName := range lotNames { - var lotPrices []float64 - if strings.HasSuffix(lotName, "*") { - pattern := strings.TrimSuffix(lotName, "*") + "%" - h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`, - pattern, req.PeriodDays).Pluck("price", &lotPrices) - } else { - h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`, - lotName, req.PeriodDays).Pluck("price", &lotPrices) - } - prices = append(prices, lotPrices...) - } - // Fall back to all time if no prices in period - if len(prices) == 0 { - prices = allPrices - } - } else { - prices = allPrices - } - - var newPrice *float64 - if len(prices) > 0 { - sortFloat64s(prices) - var basePrice float64 - if method == "average" { - basePrice = calculateAverage(prices) - } else { - basePrice = calculateMedian(prices) - } - - if req.Coefficient != 0 { - basePrice = basePrice * (1 + req.Coefficient/100) - } - newPrice = &basePrice - } - - c.JSON(http.StatusOK, gin.H{ - "lot_name": req.LotName, - "current_price": comp.CurrentPrice, - "median_all_time": medianAllTime, - "new_price": newPrice, - "quote_count_total": quoteCountTotal, - "quote_count_period": quoteCountPeriod, - "manual_price": comp.ManualPrice, - "last_price": lastPrice.Price, - "last_price_date": lastPrice.Date, - }) -} - -// sortFloat64s sorts a slice of float64 in ascending order -func sortFloat64s(data []float64) { - sort.Float64s(data) -} - -// expandMetaPricesWithCache expands meta_prices using pre-loaded lot names (no DB queries) -func expandMetaPricesWithCache(metaPrices, excludeLot string, allLotNames []string) []string { - sources := strings.Split(metaPrices, ",") - var result []string - seen := make(map[string]bool) - - for _, source := range sources { - source = strings.TrimSpace(source) - if source == "" || source == excludeLot { - continue - } - - if strings.HasSuffix(source, "*") { - // Wildcard pattern - find matching lots from cache - prefix := strings.TrimSuffix(source, "*") - for _, lot := range allLotNames { - if strings.HasPrefix(lot, prefix) && lot != excludeLot && !seen[lot] { - result = append(result, lot) - seen[lot] = true - } - } - } else if !seen[source] { - result = append(result, source) - seen[source] = true - } - } - - return result -} - -func (h *PricingHandler) ImportStockLog(c *gin.Context) { - if h.stockImportService == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{ - "error": "Импорт склада доступен только в онлайн режиме", - "offline": true, - }) - return - } - - fileHeader, err := c.FormFile("file") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"}) - return - } - - file, err := fileHeader.Open() - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "failed to open uploaded file"}) - return - } - defer file.Close() - - content, err := io.ReadAll(file) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read uploaded file"}) - return - } - modTime := time.Now() - if statter, ok := file.(interface{ Stat() (os.FileInfo, error) }); ok { - if st, statErr := statter.Stat(); statErr == nil { - modTime = st.ModTime() - } - } - - flusher, ok := c.Writer.(http.Flusher) - if !ok { - result, impErr := h.stockImportService.Import(fileHeader.Filename, content, modTime, h.dbUsername, nil) - if impErr != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": impErr.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{ - "status": "completed", - "rows_total": result.RowsTotal, - "valid_rows": result.ValidRows, - "inserted": result.Inserted, - "deleted": result.Deleted, - "unmapped": result.Unmapped, - "conflicts": result.Conflicts, - "fallback_matches": result.FallbackMatches, - "parse_errors": result.ParseErrors, - "qty_parse_errors": result.QtyParseErrors, - "ignored": result.Ignored, - "mapping_suggestions": result.MappingSuggestions, - "import_date": result.ImportDate.Format("2006-01-02"), - "warehouse_pricelist_id": result.WarehousePLID, - "warehouse_pricelist_version": result.WarehousePLVer, - }) - return - } - - c.Header("Content-Type", "text/event-stream") - c.Header("Cache-Control", "no-cache") - c.Header("Connection", "keep-alive") - c.Header("X-Accel-Buffering", "no") - - send := func(p gin.H) { - c.SSEvent("progress", p) - flusher.Flush() - } - - send(gin.H{"status": "starting", "message": "Запуск импорта"}) - _, impErr := h.stockImportService.Import(fileHeader.Filename, content, modTime, h.dbUsername, func(p services.StockImportProgress) { - send(gin.H{ - "status": p.Status, - "message": p.Message, - "current": p.Current, - "total": p.Total, - "rows_total": p.RowsTotal, - "valid_rows": p.ValidRows, - "inserted": p.Inserted, - "deleted": p.Deleted, - "unmapped": p.Unmapped, - "conflicts": p.Conflicts, - "fallback_matches": p.FallbackMatches, - "parse_errors": p.ParseErrors, - "qty_parse_errors": p.QtyParseErrors, - "ignored": p.Ignored, - "mapping_suggestions": p.MappingSuggestions, - "import_date": p.ImportDate, - "warehouse_pricelist_id": p.PricelistID, - "warehouse_pricelist_version": p.PricelistVer, - }) - }) - if impErr != nil { - send(gin.H{"status": "error", "message": impErr.Error()}) - return - } -} - -func (h *PricingHandler) ListStockMappings(c *gin.Context) { - if h.stockImportService == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{ - "error": "Сопоставления доступны только в онлайн режиме", - "offline": true, - }) - return - } - - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50")) - search := c.Query("search") - - rows, total, err := h.stockImportService.ListMappings(page, perPage, search) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "items": rows, - "total": total, - "page": page, - "per_page": perPage, - }) -} - -func (h *PricingHandler) UpsertStockMapping(c *gin.Context) { - if h.stockImportService == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{ - "error": "Сопоставления доступны только в онлайн режиме", - "offline": true, - }) - return - } - - var req struct { - Partnumber string `json:"partnumber" binding:"required"` - LotName string `json:"lot_name"` - Description string `json:"description"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if err := h.stockImportService.UpsertMapping(req.Partnumber, req.LotName, req.Description); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "mapping saved"}) -} - -func (h *PricingHandler) DeleteStockMapping(c *gin.Context) { - if h.stockImportService == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{ - "error": "Сопоставления доступны только в онлайн режиме", - "offline": true, - }) - return - } - - partnumber := c.Param("partnumber") - deleted, err := h.stockImportService.DeleteMapping(partnumber) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"deleted": deleted}) -} - -func (h *PricingHandler) ListStockIgnoreRules(c *gin.Context) { - if h.stockImportService == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{ - "error": "Правила игнорирования доступны только в онлайн режиме", - "offline": true, - }) - return - } - - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50")) - rows, total, err := h.stockImportService.ListIgnoreRules(page, perPage) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{ - "items": rows, - "total": total, - "page": page, - "per_page": perPage, - }) -} - -func (h *PricingHandler) UpsertStockIgnoreRule(c *gin.Context) { - if h.stockImportService == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{ - "error": "Правила игнорирования доступны только в онлайн режиме", - "offline": true, - }) - return - } - var req struct { - Target string `json:"target" binding:"required"` - MatchType string `json:"match_type" binding:"required"` - Pattern string `json:"pattern" binding:"required"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if err := h.stockImportService.UpsertIgnoreRule(req.Target, req.MatchType, req.Pattern); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "ignore rule saved"}) -} - -func (h *PricingHandler) DeleteStockIgnoreRule(c *gin.Context) { - if h.stockImportService == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{ - "error": "Правила игнорирования доступны только в онлайн режиме", - "offline": true, - }) - return - } - id, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil || id == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) - return - } - deleted, err := h.stockImportService.DeleteIgnoreRule(uint(id)) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"deleted": deleted}) -} - -type LotTableRow struct { - LotName string `json:"lot_name"` - LotDescription string `json:"lot_description"` - Category string `json:"category"` - Partnumbers []string `json:"partnumbers"` - Popularity float64 `json:"popularity"` - EstimateCount int64 `json:"estimate_count"` - StockQty *float64 `json:"stock_qty"` -} - -func (h *PricingHandler) ListLotsTable(c *gin.Context) { - if h.db == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{ - "error": "Список LOT доступен только в онлайн режиме", - "offline": true, - }) - return - } - - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50")) - search := strings.TrimSpace(c.Query("search")) - sortFieldParam := c.DefaultQuery("sort", "lot_name") - sortDirParam := strings.ToUpper(c.DefaultQuery("dir", "asc")) - - if page < 1 { - page = 1 - } - if perPage < 1 || perPage > 200 { - perPage = 50 - } - if sortDirParam != "ASC" && sortDirParam != "DESC" { - sortDirParam = "ASC" - } - - type lotRow struct { - LotName string `gorm:"column:lot_name"` - LotDescription string `gorm:"column:lot_description"` - CategoryCode *string `gorm:"column:category_code"` - Popularity *float64 `gorm:"column:popularity_score"` - } - - baseQuery := h.db.Table("lot"). - Select("lot.lot_name, lot.lot_description, qt_categories.code as category_code, qt_lot_metadata.popularity_score"). - Joins("LEFT JOIN qt_lot_metadata ON qt_lot_metadata.lot_name = lot.lot_name"). - Joins("LEFT JOIN qt_categories ON qt_categories.id = qt_lot_metadata.category_id") - - if search != "" { - baseQuery = baseQuery.Where("lot.lot_name LIKE ? OR lot.lot_description LIKE ?", "%"+search+"%", "%"+search+"%") - } - - var total int64 - if err := baseQuery.Count(&total).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - allowedDBSorts := map[string]string{ - "lot_name": "lot.lot_name", - "category": "qt_categories.code", - "popularity_score": "qt_lot_metadata.popularity_score", - } - needsComputedSort := sortFieldParam == "estimate_count" || sortFieldParam == "stock_qty" - - var rows []lotRow - rowsQuery := baseQuery.Session(&gorm.Session{}) - if needsComputedSort { - rowsQuery = rowsQuery.Order("lot.lot_name ASC") - } else { - orderCol, ok := allowedDBSorts[sortFieldParam] - if !ok { - orderCol = "lot.lot_name" - } - rowsQuery = rowsQuery.Order(orderCol + " " + sortDirParam).Order("lot.lot_name ASC") - rowsQuery = rowsQuery.Offset((page - 1) * perPage).Limit(perPage) - } - if err := rowsQuery.Find(&rows).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - if len(rows) == 0 { - c.JSON(http.StatusOK, gin.H{ - "lots": []LotTableRow{}, - "total": total, - "page": page, - "per_page": perPage, - }) - return - } - - // Collect lot names for batch subqueries - lotNames := make([]string, len(rows)) - for i, r := range rows { - lotNames[i] = r.LotName - } - - type countRow struct { - Lot string `gorm:"column:lot"` - Count int64 `gorm:"column:cnt"` - } - var estimateCounts []countRow - if err := h.db.Raw("SELECT lot, COUNT(*) as cnt FROM lot_log WHERE lot IN ? GROUP BY lot", lotNames).Scan(&estimateCounts).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - estimateMap := make(map[string]int64, len(estimateCounts)) - for _, ec := range estimateCounts { - estimateMap[ec.Lot] = ec.Count - } - - stockQtyByLot, pnMap, err := warehouse.LoadLotMetrics(h.db, lotNames, true) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - result := make([]LotTableRow, len(rows)) - for i, r := range rows { - cat := "" - if r.CategoryCode != nil { - cat = *r.CategoryCode - } - pop := 0.0 - if r.Popularity != nil { - pop = *r.Popularity - } - result[i] = LotTableRow{ - LotName: r.LotName, - LotDescription: r.LotDescription, - Category: cat, - Partnumbers: pnMap[r.LotName], - Popularity: pop, - EstimateCount: estimateMap[r.LotName], - } - if qty, ok := stockQtyByLot[r.LotName]; ok { - q := qty - result[i].StockQty = &q - } - if result[i].Partnumbers == nil { - result[i].Partnumbers = []string{} - } - } - - if needsComputedSort { - sort.SliceStable(result, func(i, j int) bool { - if sortFieldParam == "estimate_count" { - if result[i].EstimateCount == result[j].EstimateCount { - if sortDirParam == "DESC" { - return result[i].LotName > result[j].LotName - } - return result[i].LotName < result[j].LotName - } - if sortDirParam == "DESC" { - return result[i].EstimateCount > result[j].EstimateCount - } - return result[i].EstimateCount < result[j].EstimateCount - } - qi := 0.0 - if result[i].StockQty != nil { - qi = *result[i].StockQty - } - qj := 0.0 - if result[j].StockQty != nil { - qj = *result[j].StockQty - } - if qi == qj { - if sortDirParam == "DESC" { - return result[i].LotName > result[j].LotName - } - return result[i].LotName < result[j].LotName - } - if sortDirParam == "DESC" { - return qi > qj - } - return qi < qj - }) - - start := (page - 1) * perPage - if start >= len(result) { - result = []LotTableRow{} - } else { - end := start + perPage - if end > len(result) { - end = len(result) - } - result = result[start:end] - } - } - - c.JSON(http.StatusOK, gin.H{ - "lots": result, - "total": total, - "page": page, - "per_page": perPage, - }) -} - -func (h *PricingHandler) ListLots(c *gin.Context) { - if h.db == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{ - "error": "Список LOT доступен только в онлайн режиме", - "offline": true, - }) - return - } - perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "500")) - if perPage < 1 { - perPage = 500 - } - if perPage > 5000 { - perPage = 5000 - } - search := strings.TrimSpace(c.Query("search")) - query := h.db.Model(&models.Lot{}).Select("lot_name") - if search != "" { - query = query.Where("lot_name LIKE ?", "%"+search+"%") - } - var lots []models.Lot - if err := query.Order("lot_name ASC").Limit(perPage).Find(&lots).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - items := make([]string, 0, len(lots)) - for _, lot := range lots { - if strings.TrimSpace(lot.LotName) == "" { - continue - } - items = append(items, lot.LotName) - } - c.JSON(http.StatusOK, gin.H{"items": items}) -} diff --git a/internal/handlers/sync.go b/internal/handlers/sync.go index 98dc38c..26f897b 100644 --- a/internal/handlers/sync.go +++ b/internal/handlers/sync.go @@ -1,12 +1,14 @@ package handlers import ( + "errors" "fmt" "html/template" "log/slog" "net/http" "os" "path/filepath" + stdsync "sync" "time" qfassets "git.mchus.pro/mchus/quoteforge" @@ -24,6 +26,9 @@ type SyncHandler struct { autoSyncInterval time.Duration onlineGraceFactor float64 tmpl *template.Template + readinessMu stdsync.Mutex + readinessCached *sync.SyncReadiness + readinessCachedAt time.Time } // NewSyncHandler creates a new sync handler @@ -53,14 +58,24 @@ func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr // SyncStatusResponse represents the sync status type SyncStatusResponse struct { - LastComponentSync *time.Time `json:"last_component_sync"` - LastPricelistSync *time.Time `json:"last_pricelist_sync"` - IsOnline bool `json:"is_online"` - ComponentsCount int64 `json:"components_count"` - PricelistsCount int64 `json:"pricelists_count"` - ServerPricelists int `json:"server_pricelists"` - NeedComponentSync bool `json:"need_component_sync"` - NeedPricelistSync bool `json:"need_pricelist_sync"` + LastComponentSync *time.Time `json:"last_component_sync"` + LastPricelistSync *time.Time `json:"last_pricelist_sync"` + IsOnline bool `json:"is_online"` + ComponentsCount int64 `json:"components_count"` + PricelistsCount int64 `json:"pricelists_count"` + ServerPricelists int `json:"server_pricelists"` + NeedComponentSync bool `json:"need_component_sync"` + NeedPricelistSync bool `json:"need_pricelist_sync"` + Readiness *sync.SyncReadiness `json:"readiness,omitempty"` +} + +type SyncReadinessResponse struct { + Status string `json:"status"` + Blocked bool `json:"blocked"` + ReasonCode string `json:"reason_code,omitempty"` + ReasonText string `json:"reason_text,omitempty"` + RequiredMinAppVersion *string `json:"required_min_app_version,omitempty"` + LastCheckedAt *time.Time `json:"last_checked_at,omitempty"` } // GetStatus returns current sync status @@ -90,6 +105,7 @@ func (h *SyncHandler) GetStatus(c *gin.Context) { // Check if component sync is needed (older than 24 hours) needComponentSync := h.localDB.NeedComponentSync(24) + readiness := h.getReadinessCached(10 * time.Second) c.JSON(http.StatusOK, SyncStatusResponse{ LastComponentSync: lastComponentSync, @@ -100,9 +116,63 @@ func (h *SyncHandler) GetStatus(c *gin.Context) { ServerPricelists: serverPricelists, NeedComponentSync: needComponentSync, NeedPricelistSync: needPricelistSync, + Readiness: readiness, }) } +// GetReadiness returns sync readiness guard status. +// GET /api/sync/readiness +func (h *SyncHandler) GetReadiness(c *gin.Context) { + readiness, err := h.syncService.GetReadiness() + if err != nil && readiness == nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + }) + return + } + if readiness == nil { + c.JSON(http.StatusOK, SyncReadinessResponse{Status: sync.ReadinessUnknown, Blocked: false}) + return + } + c.JSON(http.StatusOK, SyncReadinessResponse{ + Status: readiness.Status, + Blocked: readiness.Blocked, + ReasonCode: readiness.ReasonCode, + ReasonText: readiness.ReasonText, + RequiredMinAppVersion: readiness.RequiredMinAppVersion, + LastCheckedAt: readiness.LastCheckedAt, + }) +} + +func (h *SyncHandler) ensureSyncReadiness(c *gin.Context) bool { + readiness, err := h.syncService.EnsureReadinessForSync() + if err == nil { + return true + } + + blocked := &sync.SyncBlockedError{} + if errors.As(err, &blocked) { + c.JSON(http.StatusLocked, gin.H{ + "success": false, + "error": blocked.Error(), + "reason_code": blocked.Readiness.ReasonCode, + "reason_text": blocked.Readiness.ReasonText, + "required_min_app_version": blocked.Readiness.RequiredMinAppVersion, + "status": blocked.Readiness.Status, + "blocked": true, + "last_checked_at": blocked.Readiness.LastCheckedAt, + }) + return false + } + + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "error": err.Error(), + }) + _ = readiness + return false +} + // SyncResultResponse represents sync operation result type SyncResultResponse struct { Success bool `json:"success"` @@ -114,11 +184,7 @@ type SyncResultResponse struct { // SyncComponents syncs components from MariaDB to local SQLite // POST /api/sync/components func (h *SyncHandler) SyncComponents(c *gin.Context) { - if !h.checkOnline() { - c.JSON(http.StatusServiceUnavailable, gin.H{ - "success": false, - "error": "Database is offline", - }) + if !h.ensureSyncReadiness(c) { return } @@ -153,11 +219,7 @@ func (h *SyncHandler) SyncComponents(c *gin.Context) { // SyncPricelists syncs pricelists from MariaDB to local SQLite // POST /api/sync/pricelists func (h *SyncHandler) SyncPricelists(c *gin.Context) { - if !h.checkOnline() { - c.JSON(http.StatusServiceUnavailable, gin.H{ - "success": false, - "error": "Database is offline", - }) + if !h.ensureSyncReadiness(c) { return } @@ -202,11 +264,7 @@ type SyncAllResponse struct { // - pull components, pricelists, projects, and configurations from server // POST /api/sync/all func (h *SyncHandler) SyncAll(c *gin.Context) { - if !h.checkOnline() { - c.JSON(http.StatusServiceUnavailable, gin.H{ - "success": false, - "error": "Database is offline", - }) + if !h.ensureSyncReadiness(c) { return } @@ -312,11 +370,7 @@ func (h *SyncHandler) checkOnline() bool { // PushPendingChanges pushes all pending changes to the server // POST /api/sync/push func (h *SyncHandler) PushPendingChanges(c *gin.Context) { - if !h.checkOnline() { - c.JSON(http.StatusServiceUnavailable, gin.H{ - "success": false, - "error": "Database is offline", - }) + if !h.ensureSyncReadiness(c) { return } @@ -377,9 +431,9 @@ type SyncInfoResponse struct { LastSyncAt *time.Time `json:"last_sync_at"` // Statistics - LotCount int64 `json:"lot_count"` - LotLogCount int64 `json:"lot_log_count"` - ConfigCount int64 `json:"config_count"` + LotCount int64 `json:"lot_count"` + LotLogCount int64 `json:"lot_log_count"` + ConfigCount int64 `json:"config_count"` ProjectCount int64 `json:"project_count"` // Pending changes @@ -388,6 +442,9 @@ type SyncInfoResponse struct { // Errors ErrorCount int `json:"error_count"` Errors []SyncError `json:"errors,omitempty"` + + // Readiness guard + Readiness *sync.SyncReadiness `json:"readiness,omitempty"` } type SyncUsersStatusResponse struct { @@ -459,6 +516,8 @@ func (h *SyncHandler) GetInfo(c *gin.Context) { syncErrors = syncErrors[:10] } + readiness := h.getReadinessCached(10 * time.Second) + c.JSON(http.StatusOK, SyncInfoResponse{ DBHost: dbHost, DBUser: dbUser, @@ -472,6 +531,7 @@ func (h *SyncHandler) GetInfo(c *gin.Context) { PendingChanges: changes, ErrorCount: errorCount, Errors: syncErrors, + Readiness: readiness, }) } @@ -528,12 +588,21 @@ func (h *SyncHandler) SyncStatusPartial(c *gin.Context) { // Get pending count pendingCount := h.localDB.GetPendingCount() + readiness := h.getReadinessCached(10 * time.Second) + isBlocked := readiness != nil && readiness.Blocked - slog.Debug("rendering sync status", "is_offline", isOffline, "pending_count", pendingCount) + slog.Debug("rendering sync status", "is_offline", isOffline, "pending_count", pendingCount, "sync_blocked", isBlocked) data := gin.H{ "IsOffline": isOffline, "PendingCount": pendingCount, + "IsBlocked": isBlocked, + "BlockedReason": func() string { + if readiness == nil { + return "" + } + return readiness.ReasonText + }(), } c.Header("Content-Type", "text/html; charset=utf-8") @@ -542,3 +611,24 @@ func (h *SyncHandler) SyncStatusPartial(c *gin.Context) { c.String(http.StatusInternalServerError, "Template error: "+err.Error()) } } + +func (h *SyncHandler) getReadinessCached(maxAge time.Duration) *sync.SyncReadiness { + h.readinessMu.Lock() + if h.readinessCached != nil && time.Since(h.readinessCachedAt) < maxAge { + cached := *h.readinessCached + h.readinessMu.Unlock() + return &cached + } + h.readinessMu.Unlock() + + readiness, err := h.syncService.GetReadiness() + if err != nil && readiness == nil { + return nil + } + + h.readinessMu.Lock() + h.readinessCached = readiness + h.readinessCachedAt = time.Now() + h.readinessMu.Unlock() + return readiness +} diff --git a/internal/handlers/web.go b/internal/handlers/web.go index 7a7659b..120bd63 100644 --- a/internal/handlers/web.go +++ b/internal/handlers/web.go @@ -67,7 +67,7 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer } // Load each page template with base - simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "admin_pricing.html", "pricelists.html", "pricelist_detail.html"} + simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "pricelists.html", "pricelist_detail.html"} for _, page := range simplePages { pagePath := filepath.Join(templatesPath, page) var tmpl *template.Template @@ -197,10 +197,6 @@ func (h *WebHandler) ProjectDetail(c *gin.Context) { }) } -func (h *WebHandler) AdminPricing(c *gin.Context) { - h.render(c, "admin_pricing.html", gin.H{"ActivePage": "admin"}) -} - func (h *WebHandler) Pricelists(c *gin.Context) { h.render(c, "pricelists.html", gin.H{"ActivePage": "pricelists"}) } diff --git a/internal/localdb/localdb.go b/internal/localdb/localdb.go index 85c674e..7a13bed 100644 --- a/internal/localdb/localdb.go +++ b/internal/localdb/localdb.go @@ -66,6 +66,8 @@ func New(dbPath string) (*LocalDB, error) { &LocalPricelistItem{}, &LocalComponent{}, &AppSetting{}, + &LocalRemoteMigrationApplied{}, + &LocalSyncGuardState{}, &PendingChange{}, ); err != nil { return nil, fmt.Errorf("migrating sqlite database: %w", err) @@ -418,6 +420,37 @@ func (l *LocalDB) GetConfigurationByUUID(uuid string) (*LocalConfiguration, erro return &config, err } +// ListConfigurationsWithFilters returns configurations with DB-level filtering and pagination. +func (l *LocalDB) ListConfigurationsWithFilters(status string, search string, offset, limit int) ([]LocalConfiguration, int64, error) { + query := l.db.Model(&LocalConfiguration{}) + switch status { + case "active": + query = query.Where("is_active = ?", true) + case "archived": + query = query.Where("is_active = ?", false) + case "all", "": + // no-op + default: + query = query.Where("is_active = ?", true) + } + + search = strings.TrimSpace(search) + if search != "" { + query = query.Where("LOWER(name) LIKE ?", "%"+strings.ToLower(search)+"%") + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + var configs []LocalConfiguration + if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&configs).Error; err != nil { + return nil, 0, err + } + return configs, total, nil +} + // DeleteConfiguration deletes a configuration by UUID func (l *LocalDB) DeleteConfiguration(uuid string) error { return l.DeactivateConfiguration(uuid) @@ -772,3 +805,71 @@ func (l *LocalDB) PurgeOrphanConfigurationPendingChanges() (int64, error) { func (l *LocalDB) GetPendingCount() int64 { return l.CountPendingChanges() } + +// GetRemoteMigrationApplied returns a locally applied remote migration by ID. +func (l *LocalDB) GetRemoteMigrationApplied(id string) (*LocalRemoteMigrationApplied, error) { + var migration LocalRemoteMigrationApplied + if err := l.db.Where("id = ?", id).First(&migration).Error; err != nil { + return nil, err + } + return &migration, nil +} + +// UpsertRemoteMigrationApplied writes applied migration metadata. +func (l *LocalDB) UpsertRemoteMigrationApplied(id, checksum, appVersion string, appliedAt time.Time) error { + record := &LocalRemoteMigrationApplied{ + ID: id, + Checksum: checksum, + AppVersion: appVersion, + AppliedAt: appliedAt, + } + return l.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, + DoUpdates: clause.Assignments(map[string]interface{}{ + "checksum": checksum, + "app_version": appVersion, + "applied_at": appliedAt, + }), + }).Create(record).Error +} + +// GetLatestAppliedRemoteMigrationID returns last applied remote migration id. +func (l *LocalDB) GetLatestAppliedRemoteMigrationID() (string, error) { + var record LocalRemoteMigrationApplied + if err := l.db.Order("applied_at DESC").First(&record).Error; err != nil { + return "", err + } + return record.ID, nil +} + +// GetSyncGuardState returns the latest readiness guard state. +func (l *LocalDB) GetSyncGuardState() (*LocalSyncGuardState, error) { + var state LocalSyncGuardState + if err := l.db.Order("id DESC").First(&state).Error; err != nil { + return nil, err + } + return &state, nil +} + +// SetSyncGuardState upserts readiness guard state (single-row logical table). +func (l *LocalDB) SetSyncGuardState(status, reasonCode, reasonText string, requiredMinAppVersion *string, checkedAt *time.Time) error { + state := &LocalSyncGuardState{ + ID: 1, + Status: status, + ReasonCode: reasonCode, + ReasonText: reasonText, + RequiredMinAppVersion: requiredMinAppVersion, + LastCheckedAt: checkedAt, + } + return l.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, + DoUpdates: clause.Assignments(map[string]interface{}{ + "status": status, + "reason_code": reasonCode, + "reason_text": reasonText, + "required_min_app_version": requiredMinAppVersion, + "last_checked_at": checkedAt, + "updated_at": time.Now(), + }), + }).Create(state).Error +} diff --git a/internal/repository/pricelist.go b/internal/repository/pricelist.go index d68dcc5..2152084 100644 --- a/internal/repository/pricelist.go +++ b/internal/repository/pricelist.go @@ -8,7 +8,6 @@ import ( "time" "git.mchus.pro/mchus/quoteforge/internal/models" - "git.mchus.pro/mchus/quoteforge/internal/warehouse" "gorm.io/gorm" ) @@ -244,13 +243,6 @@ func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, sear } } - var pl models.Pricelist - if err := r.db.Select("source").Where("id = ?", pricelistID).First(&pl).Error; err == nil && pl.Source == string(models.PricelistSourceWarehouse) { - if err := r.enrichWarehouseItems(items); err != nil { - return nil, 0, fmt.Errorf("enriching warehouse items: %w", err) - } - } - return items, total, nil } @@ -267,42 +259,6 @@ func (r *PricelistRepository) GetLotNames(pricelistID uint) ([]string, error) { return lotNames, nil } -func (r *PricelistRepository) enrichWarehouseItems(items []models.PricelistItem) error { - if len(items) == 0 { - return nil - } - lots := make([]string, 0, len(items)) - seen := make(map[string]struct{}, len(items)) - for _, item := range items { - lot := strings.TrimSpace(item.LotName) - if lot == "" { - continue - } - if _, ok := seen[lot]; ok { - continue - } - seen[lot] = struct{}{} - lots = append(lots, lot) - } - if len(lots) == 0 { - return nil - } - - qtyByLot, partnumbersByLot, err := warehouse.LoadLotMetrics(r.db, lots, true) - if err != nil { - return err - } - - for i := range items { - if qty, ok := qtyByLot[items[i].LotName]; ok { - q := qty - items[i].AvailableQty = &q - } - items[i].Partnumbers = partnumbersByLot[items[i].LotName] - } - return nil -} - // GetPriceForLot returns item price for a lot within a pricelist. func (r *PricelistRepository) GetPriceForLot(pricelistID uint, lotName string) (float64, error) { var item models.PricelistItem diff --git a/internal/services/alerts/service.go b/internal/services/alerts/service.go deleted file mode 100644 index c90491b..0000000 --- a/internal/services/alerts/service.go +++ /dev/null @@ -1,199 +0,0 @@ -package alerts - -import ( - "fmt" - "time" - - "git.mchus.pro/mchus/quoteforge/internal/config" - "git.mchus.pro/mchus/quoteforge/internal/models" - "git.mchus.pro/mchus/quoteforge/internal/repository" -) - -type Service struct { - alertRepo *repository.AlertRepository - componentRepo *repository.ComponentRepository - priceRepo *repository.PriceRepository - statsRepo *repository.StatsRepository - config config.AlertsConfig - pricingConfig config.PricingConfig -} - -func NewService( - alertRepo *repository.AlertRepository, - componentRepo *repository.ComponentRepository, - priceRepo *repository.PriceRepository, - statsRepo *repository.StatsRepository, - alertCfg config.AlertsConfig, - pricingCfg config.PricingConfig, -) *Service { - return &Service{ - alertRepo: alertRepo, - componentRepo: componentRepo, - priceRepo: priceRepo, - statsRepo: statsRepo, - config: alertCfg, - pricingConfig: pricingCfg, - } -} - -func (s *Service) List(filter repository.AlertFilter, page, perPage int) ([]models.PricingAlert, int64, error) { - if page < 1 { - page = 1 - } - if perPage < 1 || perPage > 100 { - perPage = 20 - } - offset := (page - 1) * perPage - - return s.alertRepo.List(filter, offset, perPage) -} - -func (s *Service) Acknowledge(id uint) error { - return s.alertRepo.UpdateStatus(id, models.AlertStatusAcknowledged) -} - -func (s *Service) Resolve(id uint) error { - return s.alertRepo.UpdateStatus(id, models.AlertStatusResolved) -} - -func (s *Service) Ignore(id uint) error { - return s.alertRepo.UpdateStatus(id, models.AlertStatusIgnored) -} - -func (s *Service) GetNewAlertsCount() (int64, error) { - return s.alertRepo.CountByStatus(models.AlertStatusNew) -} - -// CheckAndGenerateAlerts scans components and creates alerts -func (s *Service) CheckAndGenerateAlerts() error { - if !s.config.Enabled { - return nil - } - - // Get top components by usage - topComponents, err := s.statsRepo.GetTopComponents(100) - if err != nil { - return err - } - - for _, stats := range topComponents { - component, err := s.componentRepo.GetByLotName(stats.LotName) - if err != nil { - continue - } - - // Check high demand + stale price - if err := s.checkHighDemandStalePrice(component, &stats); err != nil { - continue - } - - // Check trending without price - if err := s.checkTrendingNoPrice(component, &stats); err != nil { - continue - } - - // Check no recent quotes - if err := s.checkNoRecentQuotes(component, &stats); err != nil { - continue - } - } - - return nil -} - -func (s *Service) checkHighDemandStalePrice(comp *models.LotMetadata, stats *models.ComponentUsageStats) error { - // high_demand_stale_price: >= 5 quotes/month AND price > 60 days old - if stats.QuotesLast30d < s.config.HighDemandThreshold { - return nil - } - - if comp.PriceUpdatedAt == nil { - return nil - } - - daysSinceUpdate := int(time.Since(*comp.PriceUpdatedAt).Hours() / 24) - if daysSinceUpdate <= s.pricingConfig.FreshnessYellowDays { - return nil - } - - // Check if alert already exists - exists, _ := s.alertRepo.ExistsByLotAndType(comp.LotName, models.AlertHighDemandStalePrice) - if exists { - return nil - } - - alert := &models.PricingAlert{ - LotName: comp.LotName, - AlertType: models.AlertHighDemandStalePrice, - Severity: models.SeverityCritical, - Message: fmt.Sprintf("Компонент %s: высокий спрос (%d КП/мес), но цена устарела (%d дней)", comp.LotName, stats.QuotesLast30d, daysSinceUpdate), - Details: models.AlertDetails{ - "quotes_30d": stats.QuotesLast30d, - "days_since_update": daysSinceUpdate, - }, - } - - return s.alertRepo.Create(alert) -} - -func (s *Service) checkTrendingNoPrice(comp *models.LotMetadata, stats *models.ComponentUsageStats) error { - // trending_no_price: trend > 50% AND no price - if stats.TrendDirection != models.TrendUp || stats.TrendPercent < float64(s.config.TrendingThresholdPercent) { - return nil - } - - if comp.CurrentPrice != nil && *comp.CurrentPrice > 0 { - return nil - } - - exists, _ := s.alertRepo.ExistsByLotAndType(comp.LotName, models.AlertTrendingNoPrice) - if exists { - return nil - } - - alert := &models.PricingAlert{ - LotName: comp.LotName, - AlertType: models.AlertTrendingNoPrice, - Severity: models.SeverityHigh, - Message: fmt.Sprintf("Компонент %s: рост спроса +%.0f%%, но цена не установлена", comp.LotName, stats.TrendPercent), - Details: models.AlertDetails{ - "trend_percent": stats.TrendPercent, - }, - } - - return s.alertRepo.Create(alert) -} - -func (s *Service) checkNoRecentQuotes(comp *models.LotMetadata, stats *models.ComponentUsageStats) error { - // no_recent_quotes: popular component, no supplier quotes > 90 days - if stats.QuotesLast30d < 3 { - return nil - } - - quoteCount, err := s.priceRepo.GetQuoteCount(comp.LotName, s.pricingConfig.FreshnessRedDays) - if err != nil { - return err - } - - if quoteCount > 0 { - return nil - } - - exists, _ := s.alertRepo.ExistsByLotAndType(comp.LotName, models.AlertNoRecentQuotes) - if exists { - return nil - } - - alert := &models.PricingAlert{ - LotName: comp.LotName, - AlertType: models.AlertNoRecentQuotes, - Severity: models.SeverityMedium, - Message: fmt.Sprintf("Компонент %s: популярный (%d КП), но нет новых котировок >%d дней", comp.LotName, stats.QuotesLast30d, s.pricingConfig.FreshnessRedDays), - Details: models.AlertDetails{ - "quotes_30d": stats.QuotesLast30d, - "no_quotes_days": s.pricingConfig.FreshnessRedDays, - }, - } - - return s.alertRepo.Create(alert) -} diff --git a/internal/services/local_configuration.go b/internal/services/local_configuration.go index d539b2d..84ad2db 100644 --- a/internal/services/local_configuration.go +++ b/internal/services/local_configuration.go @@ -600,26 +600,6 @@ func (s *LocalConfigurationService) ListAll(page, perPage int) ([]models.Configu // ListAllWithStatus returns configurations filtered by status: active|archived|all. func (s *LocalConfigurationService) ListAllWithStatus(page, perPage int, status string, search string) ([]models.Configuration, int64, error) { - localConfigs, err := s.localDB.GetConfigurations() - if err != nil { - return nil, 0, err - } - - search = strings.ToLower(strings.TrimSpace(search)) - configs := make([]models.Configuration, len(localConfigs)) - configs = configs[:0] - for _, lc := range localConfigs { - if !matchesConfigStatus(lc.IsActive, status) { - continue - } - if search != "" && !strings.Contains(strings.ToLower(lc.Name), search) { - continue - } - configs = append(configs, *localdb.LocalToConfiguration(&lc)) - } - - total := int64(len(configs)) - // Apply pagination if page < 1 { page = 1 @@ -628,17 +608,15 @@ func (s *LocalConfigurationService) ListAllWithStatus(page, perPage int, status perPage = 20 } offset := (page - 1) * perPage - - start := offset - if start > len(configs) { - start = len(configs) + localConfigs, total, err := s.localDB.ListConfigurationsWithFilters(status, search, offset, perPage) + if err != nil { + return nil, 0, err } - end := start + perPage - if end > len(configs) { - end = len(configs) + configs := make([]models.Configuration, 0, len(localConfigs)) + for _, lc := range localConfigs { + configs = append(configs, *localdb.LocalToConfiguration(&lc)) } - - return configs[start:end], total, nil + return configs, total, nil } // ListTemplates returns all template configurations diff --git a/internal/services/pricelist/service.go b/internal/services/pricelist/service.go deleted file mode 100644 index 2f753a0..0000000 --- a/internal/services/pricelist/service.go +++ /dev/null @@ -1,371 +0,0 @@ -package pricelist - -import ( - "errors" - "fmt" - "log/slog" - "strings" - "time" - - "git.mchus.pro/mchus/quoteforge/internal/models" - "git.mchus.pro/mchus/quoteforge/internal/repository" - "git.mchus.pro/mchus/quoteforge/internal/services/pricing" - "git.mchus.pro/mchus/quoteforge/internal/warehouse" - "gorm.io/gorm" -) - -type Service struct { - repo *repository.PricelistRepository - componentRepo *repository.ComponentRepository - pricingSvc *pricing.Service - db *gorm.DB -} - -type CreateProgress struct { - Current int - Total int - Status string - Message string - Updated int - Errors int - LotName string -} - -type CreateItemInput struct { - LotName string - Price float64 - PriceMethod string -} - -func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo *repository.ComponentRepository, pricingSvc *pricing.Service) *Service { - return &Service{ - repo: repo, - componentRepo: componentRepo, - pricingSvc: pricingSvc, - db: db, - } -} - -// CreateFromCurrentPrices creates a new pricelist by taking a snapshot of current prices -func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist, error) { - return s.CreateFromCurrentPricesForSource(createdBy, string(models.PricelistSourceEstimate)) -} - -// CreateFromCurrentPricesForSource creates a new pricelist snapshot for one source. -func (s *Service) CreateFromCurrentPricesForSource(createdBy, source string) (*models.Pricelist, error) { - return s.CreateForSourceWithProgress(createdBy, source, nil, nil) -} - -// CreateFromCurrentPricesWithProgress creates a pricelist and reports coarse-grained progress. -func (s *Service) CreateFromCurrentPricesWithProgress(createdBy, source string, onProgress func(CreateProgress)) (*models.Pricelist, error) { - return s.CreateForSourceWithProgress(createdBy, source, nil, onProgress) -} - -// CreateForSourceWithProgress creates a source pricelist from current estimate snapshot or explicit item list. -func (s *Service) CreateForSourceWithProgress(createdBy, source string, sourceItems []CreateItemInput, onProgress func(CreateProgress)) (*models.Pricelist, error) { - if s.repo == nil || s.db == nil { - return nil, fmt.Errorf("offline mode: cannot create pricelists") - } - source = string(models.NormalizePricelistSource(source)) - - report := func(p CreateProgress) { - if onProgress != nil { - onProgress(p) - } - } - report(CreateProgress{Current: 0, Total: 100, Status: "starting", Message: "Подготовка"}) - - updated, errs := 0, 0 - if source == string(models.PricelistSourceEstimate) && s.pricingSvc != nil { - report(CreateProgress{Current: 1, Total: 100, Status: "recalculating", Message: "Обновление цен компонентов"}) - updated, errs = s.pricingSvc.RecalculateAllPricesWithProgress(func(p pricing.RecalculateProgress) { - if p.Total <= 0 { - return - } - phaseCurrent := 1 + int(float64(p.Current)/float64(p.Total)*90.0) - if phaseCurrent > 91 { - phaseCurrent = 91 - } - report(CreateProgress{ - Current: phaseCurrent, - Total: 100, - Status: "recalculating", - Message: "Обновление цен компонентов", - Updated: p.Updated, - Errors: p.Errors, - LotName: p.LotName, - }) - }) - } - report(CreateProgress{Current: 92, Total: 100, Status: "recalculated", Message: "Цены обновлены", Updated: updated, Errors: errs}) - - report(CreateProgress{Current: 95, Total: 100, Status: "snapshot", Message: "Создание снимка прайслиста"}) - expiresAt := time.Now().AddDate(1, 0, 0) // +1 year - const maxCreateAttempts = 5 - var pricelist *models.Pricelist - for attempt := 1; attempt <= maxCreateAttempts; attempt++ { - version, err := s.repo.GenerateVersionBySource(source) - if err != nil { - return nil, fmt.Errorf("generating version: %w", err) - } - - pricelist = &models.Pricelist{ - Source: source, - Version: version, - CreatedBy: createdBy, - IsActive: true, - ExpiresAt: &expiresAt, - } - - if err := s.repo.Create(pricelist); err != nil { - if isVersionConflictError(err) && attempt < maxCreateAttempts { - slog.Warn("pricelist version conflict, retrying", - "attempt", attempt, - "version", version, - "error", err, - ) - time.Sleep(time.Duration(attempt*25) * time.Millisecond) - continue - } - return nil, fmt.Errorf("creating pricelist: %w", err) - } - break - } - - items := make([]models.PricelistItem, 0) - if len(sourceItems) == 0 && source == string(models.PricelistSourceWarehouse) { - warehouseItems, err := warehouse.ComputePricelistItemsFromStockLog(s.db) - if err != nil { - _ = s.repo.Delete(pricelist.ID) - return nil, fmt.Errorf("building warehouse pricelist from stock_log: %w", err) - } - sourceItems = make([]CreateItemInput, 0, len(warehouseItems)) - for _, item := range warehouseItems { - sourceItems = append(sourceItems, CreateItemInput{ - LotName: item.LotName, - Price: item.Price, - PriceMethod: item.PriceMethod, - }) - } - } - - if len(sourceItems) > 0 { - items = make([]models.PricelistItem, 0, len(sourceItems)) - for _, srcItem := range sourceItems { - if strings.TrimSpace(srcItem.LotName) == "" || srcItem.Price <= 0 { - continue - } - items = append(items, models.PricelistItem{ - PricelistID: pricelist.ID, - LotName: strings.TrimSpace(srcItem.LotName), - Price: srcItem.Price, - PriceMethod: strings.TrimSpace(srcItem.PriceMethod), - }) - } - } else { - // Default snapshot source for estimate and backward compatibility. - var metadata []models.LotMetadata - if err := s.db.Where("current_price IS NOT NULL AND current_price > 0").Find(&metadata).Error; err != nil { - return nil, fmt.Errorf("getting lot metadata: %w", err) - } - - // Create pricelist items with all price settings - items = make([]models.PricelistItem, 0, len(metadata)) - for _, m := range metadata { - if m.CurrentPrice == nil || *m.CurrentPrice <= 0 { - continue - } - items = append(items, models.PricelistItem{ - PricelistID: pricelist.ID, - LotName: m.LotName, - Price: *m.CurrentPrice, - PriceMethod: string(m.PriceMethod), - PricePeriodDays: m.PricePeriodDays, - PriceCoefficient: m.PriceCoefficient, - ManualPrice: m.ManualPrice, - MetaPrices: m.MetaPrices, - }) - } - } - - if len(items) == 0 { - _ = s.repo.Delete(pricelist.ID) - return nil, fmt.Errorf("cannot create empty pricelist for source %q", source) - } - - if err := s.repo.CreateItems(items); err != nil { - // Clean up the pricelist if items creation fails - s.repo.Delete(pricelist.ID) - return nil, fmt.Errorf("creating pricelist items: %w", err) - } - - pricelist.ItemCount = len(items) - - slog.Info("pricelist created", - "id", pricelist.ID, - "version", pricelist.Version, - "items", len(items), - "created_by", createdBy, - ) - report(CreateProgress{Current: 100, Total: 100, Status: "completed", Message: "Прайслист создан", Updated: updated, Errors: errs}) - - return pricelist, nil -} - -func isVersionConflictError(err error) bool { - if errors.Is(err, gorm.ErrDuplicatedKey) { - return true - } - msg := strings.ToLower(err.Error()) - return strings.Contains(msg, "duplicate entry") && - (strings.Contains(msg, "idx_qt_pricelists_source_version") || strings.Contains(msg, "idx_qt_pricelists_version")) -} - -// List returns pricelists with pagination -func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, error) { - return s.ListBySource(page, perPage, "") -} - -// ListBySource returns pricelists with optional source filter. -func (s *Service) ListBySource(page, perPage int, source string) ([]models.PricelistSummary, int64, error) { - // If no database connection (offline mode), return empty list - if s.repo == nil { - return []models.PricelistSummary{}, 0, nil - } - - if page < 1 { - page = 1 - } - if perPage < 1 { - perPage = 20 - } - offset := (page - 1) * perPage - return s.repo.ListBySource(source, offset, perPage) -} - -// ListActive returns active pricelists with pagination. -func (s *Service) ListActive(page, perPage int) ([]models.PricelistSummary, int64, error) { - return s.ListActiveBySource(page, perPage, "") -} - -// ListActiveBySource returns active pricelists with optional source filter. -func (s *Service) ListActiveBySource(page, perPage int, source string) ([]models.PricelistSummary, int64, error) { - if s.repo == nil { - return []models.PricelistSummary{}, 0, nil - } - if page < 1 { - page = 1 - } - if perPage < 1 { - perPage = 20 - } - offset := (page - 1) * perPage - return s.repo.ListActiveBySource(source, offset, perPage) -} - -// GetByID returns a pricelist by ID -func (s *Service) GetByID(id uint) (*models.Pricelist, error) { - if s.repo == nil { - return nil, fmt.Errorf("offline mode: pricelist service not available") - } - return s.repo.GetByID(id) -} - -// GetItems returns pricelist items with pagination -func (s *Service) GetItems(pricelistID uint, page, perPage int, search string) ([]models.PricelistItem, int64, error) { - if s.repo == nil { - return []models.PricelistItem{}, 0, nil - } - if page < 1 { - page = 1 - } - if perPage < 1 { - perPage = 50 - } - offset := (page - 1) * perPage - return s.repo.GetItems(pricelistID, offset, perPage, search) -} - -func (s *Service) GetLotNames(pricelistID uint) ([]string, error) { - if s.repo == nil { - return []string{}, nil - } - return s.repo.GetLotNames(pricelistID) -} - -// Delete deletes a pricelist by ID -func (s *Service) Delete(id uint) error { - if s.repo == nil { - return fmt.Errorf("offline mode: cannot delete pricelists") - } - return s.repo.Delete(id) -} - -// SetActive toggles active state for a pricelist. -func (s *Service) SetActive(id uint, isActive bool) error { - if s.repo == nil { - return fmt.Errorf("offline mode: cannot update pricelists") - } - return s.repo.SetActive(id, isActive) -} - -// GetPriceForLot returns price by pricelist/lot. -func (s *Service) GetPriceForLot(pricelistID uint, lotName string) (float64, error) { - if s.repo == nil { - return 0, fmt.Errorf("offline mode: pricelist service not available") - } - return s.repo.GetPriceForLot(pricelistID, lotName) -} - -// CanWrite returns true if the user can create pricelists -func (s *Service) CanWrite() bool { - if s.repo == nil { - return false - } - return s.repo.CanWrite() -} - -// CanWriteDebug returns write permission status with debug info -func (s *Service) CanWriteDebug() (bool, string) { - if s.repo == nil { - return false, "offline mode" - } - return s.repo.CanWriteDebug() -} - -// GetLatestActive returns the most recent active pricelist -func (s *Service) GetLatestActive() (*models.Pricelist, error) { - return s.GetLatestActiveBySource(string(models.PricelistSourceEstimate)) -} - -// GetLatestActiveBySource returns the latest active pricelist for a source. -func (s *Service) GetLatestActiveBySource(source string) (*models.Pricelist, error) { - if s.repo == nil { - return nil, fmt.Errorf("offline mode: pricelist service not available") - } - return s.repo.GetLatestActiveBySource(source) -} - -// CleanupExpired deletes expired and unused pricelists -func (s *Service) CleanupExpired() (int, error) { - if s.repo == nil { - return 0, fmt.Errorf("offline mode: cleanup not available") - } - - expired, err := s.repo.GetExpiredUnused() - if err != nil { - return 0, err - } - - deleted := 0 - for _, pl := range expired { - if err := s.repo.Delete(pl.ID); err != nil { - slog.Warn("failed to delete expired pricelist", "id", pl.ID, "error", err) - continue - } - deleted++ - } - - slog.Info("cleaned up expired pricelists", "deleted", deleted) - return deleted, nil -} diff --git a/internal/services/pricelist/service_warehouse_test.go b/internal/services/pricelist/service_warehouse_test.go deleted file mode 100644 index 0a65ee9..0000000 --- a/internal/services/pricelist/service_warehouse_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package pricelist - -import ( - "math" - "testing" - "time" - - "git.mchus.pro/mchus/quoteforge/internal/models" - "git.mchus.pro/mchus/quoteforge/internal/repository" - "github.com/glebarez/sqlite" - "gorm.io/gorm" -) - -func TestCreateWarehousePricelistFromStockLog(t *testing.T) { - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - - if err := db.AutoMigrate( - &models.Pricelist{}, - &models.PricelistItem{}, - &models.StockLog{}, - &models.Lot{}, - &models.LotPartnumber{}, - ); err != nil { - t.Fatalf("automigrate: %v", err) - } - - if err := db.Create(&models.Lot{LotName: "CPU_X", LotDescription: "CPU"}).Error; err != nil { - t.Fatalf("seed lot: %v", err) - } - if err := db.Create(&models.LotPartnumber{Partnumber: "PN-CPU-X", LotName: "CPU_X"}).Error; err != nil { - t.Fatalf("seed mapping: %v", err) - } - - qty1 := 2.0 - qty2 := 8.0 - now := time.Now() - rows := []models.StockLog{ - {Partnumber: "PN-CPU-X", Date: now, Price: 100, Qty: &qty1}, - {Partnumber: "PN-CPU-X", Date: now, Price: 200, Qty: &qty2}, - } - if err := db.Create(&rows).Error; err != nil { - t.Fatalf("seed stock log: %v", err) - } - - repo := repository.NewPricelistRepository(db) - svc := NewService(db, repo, nil, nil) - - pl, err := svc.CreateForSourceWithProgress("tester", string(models.PricelistSourceWarehouse), nil, nil) - if err != nil { - t.Fatalf("create warehouse pricelist: %v", err) - } - if pl.Source != string(models.PricelistSourceWarehouse) { - t.Fatalf("unexpected source: %s", pl.Source) - } - - var items []models.PricelistItem - if err := db.Where("pricelist_id = ?", pl.ID).Find(&items).Error; err != nil { - t.Fatalf("load pricelist items: %v", err) - } - if len(items) != 1 { - t.Fatalf("expected 1 item, got %d", len(items)) - } - if items[0].LotName != "CPU_X" { - t.Fatalf("unexpected lot name: %s", items[0].LotName) - } - if math.Abs(items[0].Price-200) > 0.001 { - t.Fatalf("expected weighted median price 200, got %f", items[0].Price) - } -} diff --git a/internal/services/pricing/calculator.go b/internal/services/pricing/calculator.go deleted file mode 100644 index 1f7612a..0000000 --- a/internal/services/pricing/calculator.go +++ /dev/null @@ -1,121 +0,0 @@ -package pricing - -import ( - "math" - "sort" - "time" - - "git.mchus.pro/mchus/quoteforge/internal/repository" -) - -// CalculateMedian returns the median of prices -func CalculateMedian(prices []float64) float64 { - if len(prices) == 0 { - return 0 - } - - sorted := make([]float64, len(prices)) - copy(sorted, prices) - sort.Float64s(sorted) - - n := len(sorted) - if n%2 == 0 { - return (sorted[n/2-1] + sorted[n/2]) / 2 - } - return sorted[n/2] -} - -// CalculateAverage returns the arithmetic mean of prices -func CalculateAverage(prices []float64) float64 { - if len(prices) == 0 { - return 0 - } - - var sum float64 - for _, p := range prices { - sum += p - } - return sum / float64(len(prices)) -} - -// CalculateWeightedMedian calculates median with exponential decay weights -// More recent prices have higher weight -func CalculateWeightedMedian(points []repository.PricePoint, decayDays int) float64 { - if len(points) == 0 { - return 0 - } - - type weightedPrice struct { - price float64 - weight float64 - } - - now := time.Now() - weighted := make([]weightedPrice, len(points)) - var totalWeight float64 - - for i, p := range points { - daysSince := now.Sub(p.Date).Hours() / 24 - // weight = e^(-days / decay_days) - weight := math.Exp(-daysSince / float64(decayDays)) - weighted[i] = weightedPrice{price: p.Price, weight: weight} - totalWeight += weight - } - - // Sort by price - sort.Slice(weighted, func(i, j int) bool { - return weighted[i].price < weighted[j].price - }) - - // Find weighted median - targetWeight := totalWeight / 2 - var cumulativeWeight float64 - - for _, wp := range weighted { - cumulativeWeight += wp.weight - if cumulativeWeight >= targetWeight { - return wp.price - } - } - - return weighted[len(weighted)-1].price -} - -// CalculatePercentile calculates the nth percentile of prices -func CalculatePercentile(prices []float64, percentile float64) float64 { - if len(prices) == 0 { - return 0 - } - - sorted := make([]float64, len(prices)) - copy(sorted, prices) - sort.Float64s(sorted) - - index := (percentile / 100) * float64(len(sorted)-1) - lower := int(math.Floor(index)) - upper := int(math.Ceil(index)) - - if lower == upper { - return sorted[lower] - } - - fraction := index - float64(lower) - return sorted[lower]*(1-fraction) + sorted[upper]*fraction -} - -// CalculateStdDev calculates standard deviation -func CalculateStdDev(prices []float64) float64 { - if len(prices) < 2 { - return 0 - } - - mean := CalculateAverage(prices) - var sumSquares float64 - - for _, p := range prices { - diff := p - mean - sumSquares += diff * diff - } - - return math.Sqrt(sumSquares / float64(len(prices)-1)) -} diff --git a/internal/services/pricing/service.go b/internal/services/pricing/service.go deleted file mode 100644 index 1530903..0000000 --- a/internal/services/pricing/service.go +++ /dev/null @@ -1,378 +0,0 @@ -package pricing - -import ( - "strings" - "time" - - "git.mchus.pro/mchus/quoteforge/internal/config" - "git.mchus.pro/mchus/quoteforge/internal/models" - "git.mchus.pro/mchus/quoteforge/internal/repository" - "gorm.io/gorm" -) - -type Service struct { - componentRepo *repository.ComponentRepository - priceRepo *repository.PriceRepository - config config.PricingConfig - db *gorm.DB -} - -type RecalculateProgress struct { - Current int - Total int - LotName string - Updated int - Errors int -} - -func NewService( - componentRepo *repository.ComponentRepository, - priceRepo *repository.PriceRepository, - cfg config.PricingConfig, -) *Service { - var db *gorm.DB - if componentRepo != nil { - db = componentRepo.DB() - } - - return &Service{ - componentRepo: componentRepo, - priceRepo: priceRepo, - config: cfg, - db: db, - } -} - -// GetEffectivePrice returns the current effective price for a component -// Priority: active override > calculated price > nil -func (s *Service) GetEffectivePrice(lotName string) (*float64, error) { - // Check for active override first - override, err := s.priceRepo.GetPriceOverride(lotName) - if err == nil && override != nil { - return &override.Price, nil - } - - // Get component metadata - component, err := s.componentRepo.GetByLotName(lotName) - if err != nil { - return nil, err - } - - return component.CurrentPrice, nil -} - -// CalculatePrice calculates price using the specified method -func (s *Service) CalculatePrice(lotName string, method models.PriceMethod, periodDays int) (float64, error) { - if periodDays == 0 { - periodDays = s.config.DefaultPeriodDays - } - - points, err := s.priceRepo.GetPriceHistory(lotName, periodDays) - if err != nil { - return 0, err - } - - if len(points) == 0 { - return 0, nil - } - - prices := make([]float64, len(points)) - for i, p := range points { - prices[i] = p.Price - } - - switch method { - case models.PriceMethodAverage: - return CalculateAverage(prices), nil - case models.PriceMethodWeightedMedian: - return CalculateWeightedMedian(points, periodDays), nil - case models.PriceMethodMedian: - fallthrough - default: - return CalculateMedian(prices), nil - } -} - -// UpdateComponentPrice recalculates and updates the price for a component -func (s *Service) UpdateComponentPrice(lotName string) error { - component, err := s.componentRepo.GetByLotName(lotName) - if err != nil { - return err - } - - price, err := s.CalculatePrice(lotName, component.PriceMethod, component.PricePeriodDays) - if err != nil { - return err - } - - now := time.Now() - if price > 0 { - component.CurrentPrice = &price - component.PriceUpdatedAt = &now - } - - return s.componentRepo.Update(component) -} - -// SetManualPrice sets a manual price override -func (s *Service) SetManualPrice(lotName string, price float64, reason string, userID uint) error { - override := &models.PriceOverride{ - LotName: lotName, - Price: price, - ValidFrom: time.Now(), - Reason: reason, - CreatedBy: userID, - } - return s.priceRepo.CreatePriceOverride(override) -} - -// UpdatePriceMethod changes the pricing method for a component -func (s *Service) UpdatePriceMethod(lotName string, method models.PriceMethod, periodDays int) error { - component, err := s.componentRepo.GetByLotName(lotName) - if err != nil { - return err - } - - component.PriceMethod = method - if periodDays > 0 { - component.PricePeriodDays = periodDays - } - - if err := s.componentRepo.Update(component); err != nil { - return err - } - - return s.UpdateComponentPrice(lotName) -} - -// GetPriceStats returns statistics for a component's price history -func (s *Service) GetPriceStats(lotName string, periodDays int) (*PriceStats, error) { - if periodDays == 0 { - periodDays = s.config.DefaultPeriodDays - } - - points, err := s.priceRepo.GetPriceHistory(lotName, periodDays) - if err != nil { - return nil, err - } - - if len(points) == 0 { - return &PriceStats{QuoteCount: 0}, nil - } - - prices := make([]float64, len(points)) - for i, p := range points { - prices[i] = p.Price - } - - return &PriceStats{ - QuoteCount: len(points), - MinPrice: CalculatePercentile(prices, 0), - MaxPrice: CalculatePercentile(prices, 100), - MedianPrice: CalculateMedian(prices), - AveragePrice: CalculateAverage(prices), - StdDeviation: CalculateStdDev(prices), - LatestPrice: points[0].Price, - LatestDate: points[0].Date, - OldestDate: points[len(points)-1].Date, - Percentile25: CalculatePercentile(prices, 25), - Percentile75: CalculatePercentile(prices, 75), - }, nil -} - -type PriceStats struct { - QuoteCount int `json:"quote_count"` - MinPrice float64 `json:"min_price"` - MaxPrice float64 `json:"max_price"` - MedianPrice float64 `json:"median_price"` - AveragePrice float64 `json:"average_price"` - StdDeviation float64 `json:"std_deviation"` - LatestPrice float64 `json:"latest_price"` - LatestDate time.Time `json:"latest_date"` - OldestDate time.Time `json:"oldest_date"` - Percentile25 float64 `json:"percentile_25"` - Percentile75 float64 `json:"percentile_75"` -} - -// RecalculateAllPrices recalculates prices for all components -func (s *Service) RecalculateAllPrices() (updated int, errors int) { - return s.RecalculateAllPricesWithProgress(nil) -} - -// RecalculateAllPricesWithProgress recalculates prices and reports progress. -func (s *Service) RecalculateAllPricesWithProgress(onProgress func(RecalculateProgress)) (updated int, errors int) { - if s.db == nil { - return 0, 0 - } - - // Logic mirrors "Обновить цены" in admin pricing. - var components []models.LotMetadata - if err := s.db.Find(&components).Error; err != nil { - return 0, len(components) - } - total := len(components) - - var allLotNames []string - _ = s.db.Model(&models.LotMetadata{}).Pluck("lot_name", &allLotNames).Error - - type lotDate struct { - Lot string - Date time.Time - } - var latestDates []lotDate - _ = s.db.Raw(`SELECT lot, MAX(date) as date FROM lot_log GROUP BY lot`).Scan(&latestDates).Error - lotLatestDate := make(map[string]time.Time, len(latestDates)) - for _, ld := range latestDates { - lotLatestDate[ld.Lot] = ld.Date - } - - var skipped, manual, unchanged int - now := time.Now() - current := 0 - - for _, comp := range components { - current++ - reportProgress := func() { - if onProgress != nil && (current%10 == 0 || current == total) { - onProgress(RecalculateProgress{ - Current: current, - Total: total, - LotName: comp.LotName, - Updated: updated, - Errors: errors, - }) - } - } - - if comp.ManualPrice != nil && *comp.ManualPrice > 0 { - manual++ - reportProgress() - continue - } - - method := comp.PriceMethod - if method == "" { - method = models.PriceMethodMedian - } - - var sourceLots []string - if comp.MetaPrices != "" { - sourceLots = expandMetaPricesWithCache(comp.MetaPrices, comp.LotName, allLotNames) - } else { - sourceLots = []string{comp.LotName} - } - if len(sourceLots) == 0 { - skipped++ - reportProgress() - continue - } - - if comp.PriceUpdatedAt != nil { - hasNewData := false - for _, lot := range sourceLots { - if latestDate, ok := lotLatestDate[lot]; ok && latestDate.After(*comp.PriceUpdatedAt) { - hasNewData = true - break - } - } - if !hasNewData { - unchanged++ - reportProgress() - continue - } - } - - var prices []float64 - if comp.PricePeriodDays > 0 { - _ = s.db.Raw( - `SELECT price FROM lot_log WHERE lot IN ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`, - sourceLots, comp.PricePeriodDays, - ).Pluck("price", &prices).Error - } else { - _ = s.db.Raw( - `SELECT price FROM lot_log WHERE lot IN ? ORDER BY price`, - sourceLots, - ).Pluck("price", &prices).Error - } - - if len(prices) == 0 && comp.PricePeriodDays > 0 { - _ = s.db.Raw(`SELECT price FROM lot_log WHERE lot IN ? ORDER BY price`, sourceLots).Pluck("price", &prices).Error - } - if len(prices) == 0 { - skipped++ - reportProgress() - continue - } - - var basePrice float64 - switch method { - case models.PriceMethodAverage: - basePrice = CalculateAverage(prices) - default: - basePrice = CalculateMedian(prices) - } - if basePrice <= 0 { - skipped++ - reportProgress() - continue - } - - finalPrice := basePrice - if comp.PriceCoefficient != 0 { - finalPrice = finalPrice * (1 + comp.PriceCoefficient/100) - } - - if err := s.db.Model(&models.LotMetadata{}). - Where("lot_name = ?", comp.LotName). - Updates(map[string]interface{}{ - "current_price": finalPrice, - "price_updated_at": now, - }).Error; err != nil { - errors++ - } else { - updated++ - } - - reportProgress() - } - - if onProgress != nil && total == 0 { - onProgress(RecalculateProgress{ - Current: 0, - Total: 0, - LotName: "", - Updated: updated, - Errors: errors, - }) - } - - return updated, errors -} - -func expandMetaPricesWithCache(metaPrices, excludeLot string, allLotNames []string) []string { - sources := strings.Split(metaPrices, ",") - var result []string - seen := make(map[string]bool) - - for _, source := range sources { - source = strings.TrimSpace(source) - if source == "" || source == excludeLot { - continue - } - - if strings.HasSuffix(source, "*") { - prefix := strings.TrimSuffix(source, "*") - for _, lot := range allLotNames { - if strings.HasPrefix(lot, prefix) && lot != excludeLot && !seen[lot] { - result = append(result, lot) - seen[lot] = true - } - } - } else if !seen[source] { - result = append(result, source) - seen[source] = true - } - } - - return result -} diff --git a/internal/services/quote.go b/internal/services/quote.go index ea2520f..d38e7ed 100644 --- a/internal/services/quote.go +++ b/internal/services/quote.go @@ -9,7 +9,6 @@ import ( "git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/repository" - "git.mchus.pro/mchus/quoteforge/internal/services/pricing" ) var ( @@ -23,18 +22,22 @@ type QuoteService struct { statsRepo *repository.StatsRepository pricelistRepo *repository.PricelistRepository localDB *localdb.LocalDB - pricingService *pricing.Service + pricingService priceResolver cacheMu sync.RWMutex priceCache map[string]cachedLotPrice cacheTTL time.Duration } +type priceResolver interface { + GetEffectivePrice(lotName string) (*float64, error) +} + func NewQuoteService( componentRepo *repository.ComponentRepository, statsRepo *repository.StatsRepository, pricelistRepo *repository.PricelistRepository, localDB *localdb.LocalDB, - pricingService *pricing.Service, + pricingService priceResolver, ) *QuoteService { return &QuoteService{ componentRepo: componentRepo, @@ -110,8 +113,53 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation if len(req.Items) == 0 { return nil, ErrEmptyQuote } + + // Strict local-first path: calculations use local SQLite snapshot regardless of online status. + if s.localDB != nil { + result := &QuoteValidationResult{ + Valid: true, + Items: make([]QuoteItem, 0, len(req.Items)), + Errors: make([]string, 0), + Warnings: make([]string, 0), + } + + var total float64 + for _, reqItem := range req.Items { + localComp, err := s.localDB.GetLocalComponent(reqItem.LotName) + if err != nil { + result.Valid = false + result.Errors = append(result.Errors, "Component not found: "+reqItem.LotName) + continue + } + + item := QuoteItem{ + LotName: reqItem.LotName, + Quantity: reqItem.Quantity, + Description: localComp.LotDescription, + Category: localComp.Category, + HasPrice: false, + UnitPrice: 0, + TotalPrice: 0, + } + + if localComp.CurrentPrice != nil && *localComp.CurrentPrice > 0 { + item.UnitPrice = *localComp.CurrentPrice + item.TotalPrice = *localComp.CurrentPrice * float64(reqItem.Quantity) + item.HasPrice = true + total += item.TotalPrice + } else { + result.Warnings = append(result.Warnings, "No price available for: "+reqItem.LotName) + } + + result.Items = append(result.Items, item) + } + + result.Total = total + return result, nil + } + if s.componentRepo == nil || s.pricingService == nil { - return nil, errors.New("offline mode: quote calculation not available") + return nil, errors.New("quote calculation not available") } result := &QuoteValidationResult{ diff --git a/internal/services/stock_import.go b/internal/services/stock_import.go deleted file mode 100644 index d7a16e6..0000000 --- a/internal/services/stock_import.go +++ /dev/null @@ -1,1085 +0,0 @@ -package services - -import ( - "archive/zip" - "bytes" - "encoding/xml" - "fmt" - "io" - "path/filepath" - "regexp" - "sort" - "strconv" - "strings" - "time" - - "git.mchus.pro/mchus/quoteforge/internal/lotmatch" - "git.mchus.pro/mchus/quoteforge/internal/models" - pricelistsvc "git.mchus.pro/mchus/quoteforge/internal/services/pricelist" - "git.mchus.pro/mchus/quoteforge/internal/warehouse" - "gorm.io/gorm" - "gorm.io/gorm/clause" -) - -type StockImportProgress struct { - Status string `json:"status"` - Message string `json:"message,omitempty"` - Current int `json:"current,omitempty"` - Total int `json:"total,omitempty"` - RowsTotal int `json:"rows_total,omitempty"` - ValidRows int `json:"valid_rows,omitempty"` - Inserted int `json:"inserted,omitempty"` - Deleted int64 `json:"deleted,omitempty"` - Unmapped int `json:"unmapped,omitempty"` - Conflicts int `json:"conflicts,omitempty"` - FallbackMatches int `json:"fallback_matches,omitempty"` - ParseErrors int `json:"parse_errors,omitempty"` - QtyParseErrors int `json:"qty_parse_errors,omitempty"` - Ignored int `json:"ignored,omitempty"` - MappingSuggestions []StockMappingSuggestion `json:"mapping_suggestions,omitempty"` - ImportDate string `json:"import_date,omitempty"` - PricelistID uint `json:"warehouse_pricelist_id,omitempty"` - PricelistVer string `json:"warehouse_pricelist_version,omitempty"` -} - -type StockImportResult struct { - RowsTotal int - ValidRows int - Inserted int - Deleted int64 - Unmapped int - Conflicts int - FallbackMatches int - ParseErrors int - QtyParseErrors int - Ignored int - MappingSuggestions []StockMappingSuggestion - ImportDate time.Time - WarehousePLID uint - WarehousePLVer string -} - -type StockMappingSuggestion struct { - Partnumber string `json:"partnumber"` - Description string `json:"description,omitempty"` - Reason string `json:"reason,omitempty"` -} - -type stockIgnoreRule struct { - Target string - MatchType string - Pattern string -} - -type StockImportService struct { - db *gorm.DB - pricelistSvc *pricelistsvc.Service -} - -func NewStockImportService(db *gorm.DB, pricelistSvc *pricelistsvc.Service) *StockImportService { - return &StockImportService{ - db: db, - pricelistSvc: pricelistSvc, - } -} - -type stockImportRow struct { - Folder string - Article string - Description string - Vendor string - Price float64 - Qty float64 - QtyRaw string - QtyInvalid bool -} - -type weightedPricePoint struct { - price float64 - weight float64 -} - -func (s *StockImportService) Import( - filename string, - content []byte, - fileModTime time.Time, - createdBy string, - onProgress func(StockImportProgress), -) (*StockImportResult, error) { - if s.db == nil { - return nil, fmt.Errorf("offline mode: stock import unavailable") - } - if len(content) == 0 { - return nil, fmt.Errorf("empty file") - } - report := func(p StockImportProgress) { - if onProgress != nil { - onProgress(p) - } - } - - report(StockImportProgress{Status: "starting", Message: "Запуск импорта", Current: 0, Total: 100}) - - rows, err := parseStockRows(filename, content) - if err != nil { - return nil, err - } - if len(rows) == 0 { - return nil, fmt.Errorf("no rows parsed") - } - report(StockImportProgress{Status: "parsing", Message: "Файл распарсен", RowsTotal: len(rows), Current: 10, Total: 100}) - - importDate := detectImportDate(content, filename, fileModTime) - report(StockImportProgress{ - Status: "parsing", - Message: "Дата импорта определена", - ImportDate: importDate.Format("2006-01-02"), - Current: 15, - Total: 100, - }) - - partnumberMatcher, err := lotmatch.NewMappingMatcherFromDB(s.db) - if err != nil { - return nil, err - } - - var ( - records []models.StockLog - unmapped int - conflicts int - fallbackMatches int - parseErrors int - qtyParseErrors int - ignored int - suggestionsByPN = make(map[string]StockMappingSuggestion) - ) - ignoreRules, err := s.loadIgnoreRules() - if err != nil { - return nil, err - } - - for _, row := range rows { - if strings.TrimSpace(row.Article) == "" { - parseErrors++ - continue - } - if row.QtyInvalid { - qtyParseErrors++ - parseErrors++ - continue - } - if shouldIgnoreStockRow(row, ignoreRules) { - ignored++ - continue - } - partnumber := strings.TrimSpace(row.Article) - key := normalizeKey(partnumber) - mappedLots := partnumberMatcher.MatchLots(partnumber) - if len(mappedLots) == 0 { - unmapped++ - suggestionsByPN[key] = upsertSuggestion(suggestionsByPN[key], StockMappingSuggestion{ - Partnumber: partnumber, - Description: strings.TrimSpace(row.Description), - Reason: "unmapped", - }) - } else if len(mappedLots) > 1 { - conflicts++ - suggestionsByPN[key] = upsertSuggestion(suggestionsByPN[key], StockMappingSuggestion{ - Partnumber: partnumber, - Description: strings.TrimSpace(row.Description), - Reason: "conflict", - }) - } - - var comments *string - if trimmed := strings.TrimSpace(row.Description); trimmed != "" { - comments = &trimmed - } - var vendor *string - if trimmed := strings.TrimSpace(row.Vendor); trimmed != "" { - vendor = &trimmed - } - qty := row.Qty - records = append(records, models.StockLog{ - Partnumber: partnumber, - Date: importDate, - Price: row.Price, - Comments: comments, - Vendor: vendor, - Qty: &qty, - }) - } - - suggestions := collectSortedSuggestions(suggestionsByPN, 200) - - if len(records) == 0 { - return nil, fmt.Errorf("no valid rows after filtering") - } - - report(StockImportProgress{ - Status: "mapping", - Message: "Валидация строк завершена", - RowsTotal: len(rows), - ValidRows: len(records), - Unmapped: unmapped, - Conflicts: conflicts, - FallbackMatches: fallbackMatches, - ParseErrors: parseErrors, - QtyParseErrors: qtyParseErrors, - Current: 40, - Total: 100, - }) - - deleted, inserted, err := s.replaceStockLogs(records) - if err != nil { - return nil, err - } - - report(StockImportProgress{ - Status: "writing", - Message: "Данные stock_log обновлены", - Inserted: inserted, - Deleted: deleted, - Current: 60, - Total: 100, - ImportDate: importDate.Format("2006-01-02"), - }) - - items, err := s.buildWarehousePricelistItems() - if err != nil { - return nil, err - } - if len(items) == 0 { - return nil, fmt.Errorf("stock_log does not contain positive prices for warehouse pricelist") - } - - if createdBy == "" { - createdBy = "unknown" - } - - report(StockImportProgress{Status: "recalculating_warehouse", Message: "Создание warehouse прайслиста", Current: 70, Total: 100}) - var warehousePLID uint - var warehousePLVer string - if s.pricelistSvc == nil { - return nil, fmt.Errorf("pricelist service unavailable") - } - pl, err := s.pricelistSvc.CreateForSourceWithProgress(createdBy, string(models.PricelistSourceWarehouse), items, func(p pricelistsvc.CreateProgress) { - current := 70 + int(float64(p.Current)*0.3) - if p.Status != "completed" && current >= 100 { - current = 99 - } - report(StockImportProgress{ - Status: "recalculating_warehouse", - Message: p.Message, - Current: current, - Total: 100, - }) - }) - if err != nil { - return nil, err - } - warehousePLID = pl.ID - warehousePLVer = pl.Version - - result := &StockImportResult{ - RowsTotal: len(rows), - ValidRows: len(records), - Inserted: inserted, - Deleted: deleted, - Unmapped: unmapped, - Conflicts: conflicts, - FallbackMatches: fallbackMatches, - ParseErrors: parseErrors, - QtyParseErrors: qtyParseErrors, - Ignored: ignored, - MappingSuggestions: suggestions, - ImportDate: importDate, - WarehousePLID: warehousePLID, - WarehousePLVer: warehousePLVer, - } - - report(StockImportProgress{ - Status: "completed", - Message: "Импорт завершен", - RowsTotal: result.RowsTotal, - ValidRows: result.ValidRows, - Inserted: result.Inserted, - Deleted: result.Deleted, - Unmapped: result.Unmapped, - Conflicts: result.Conflicts, - FallbackMatches: result.FallbackMatches, - ParseErrors: result.ParseErrors, - QtyParseErrors: result.QtyParseErrors, - Ignored: result.Ignored, - MappingSuggestions: result.MappingSuggestions, - ImportDate: result.ImportDate.Format("2006-01-02"), - PricelistID: result.WarehousePLID, - PricelistVer: result.WarehousePLVer, - Current: 100, - Total: 100, - }) - - return result, nil -} - -func (s *StockImportService) replaceStockLogs(records []models.StockLog) (int64, int, error) { - var deleted int64 - err := s.db.Transaction(func(tx *gorm.DB) error { - res := tx.Exec("DELETE FROM stock_log") - if res.Error != nil { - return res.Error - } - deleted = res.RowsAffected - - if err := tx.CreateInBatches(records, 500).Error; err != nil { - return err - } - return nil - }) - if err != nil { - return 0, 0, err - } - return deleted, len(records), nil -} - -func (s *StockImportService) buildWarehousePricelistItems() ([]pricelistsvc.CreateItemInput, error) { - warehouseItems, err := warehouse.ComputePricelistItemsFromStockLog(s.db) - if err != nil { - return nil, err - } - - items := make([]pricelistsvc.CreateItemInput, 0, len(warehouseItems)) - for _, item := range warehouseItems { - items = append(items, pricelistsvc.CreateItemInput{ - LotName: item.LotName, - Price: item.Price, - PriceMethod: item.PriceMethod, - }) - } - return items, nil -} - -func upsertSuggestion(prev StockMappingSuggestion, candidate StockMappingSuggestion) StockMappingSuggestion { - if strings.TrimSpace(prev.Partnumber) == "" { - return candidate - } - if strings.TrimSpace(prev.Description) == "" && strings.TrimSpace(candidate.Description) != "" { - prev.Description = candidate.Description - } - if prev.Reason != "conflict" && candidate.Reason == "conflict" { - prev.Reason = "conflict" - } - return prev -} - -func (s *StockImportService) ListMappings(page, perPage int, search string) ([]models.LotPartnumber, int64, error) { - if s.db == nil { - return nil, 0, fmt.Errorf("offline mode: mappings unavailable") - } - if page < 1 { - page = 1 - } - if perPage < 1 { - perPage = 50 - } - if perPage > 500 { - perPage = 500 - } - - offset := (page - 1) * perPage - query := s.db.Model(&models.LotPartnumber{}) - if search = strings.TrimSpace(search); search != "" { - like := "%" + search + "%" - query = query.Where("partnumber LIKE ? OR lot_name LIKE ? OR description LIKE ?", like, like, like) - } - - var total int64 - if err := query.Count(&total).Error; err != nil { - return nil, 0, err - } - - var rows []models.LotPartnumber - if err := query.Order("CASE WHEN TRIM(lot_name) = '' THEN 0 ELSE 1 END, partnumber ASC").Offset(offset).Limit(perPage).Find(&rows).Error; err != nil { - return nil, 0, err - } - return rows, total, nil -} - -func (s *StockImportService) UpsertMapping(partnumber, lotName, description string) error { - if s.db == nil { - return fmt.Errorf("offline mode: mappings unavailable") - } - partnumber = strings.TrimSpace(partnumber) - lotName = strings.TrimSpace(lotName) - description = strings.TrimSpace(description) - if partnumber == "" { - return fmt.Errorf("partnumber is required") - } - - if lotName != "" { - var lotCount int64 - if err := s.db.Model(&models.Lot{}).Where("lot_name = ?", lotName).Count(&lotCount).Error; err != nil { - return err - } - if lotCount == 0 { - return fmt.Errorf("lot not found: %s", lotName) - } - } - - return s.db.Transaction(func(tx *gorm.DB) error { - var existing []models.LotPartnumber - if err := tx.Where("LOWER(TRIM(partnumber)) = LOWER(TRIM(?))", partnumber).Find(&existing).Error; err != nil { - return err - } - if description == "" { - for _, row := range existing { - if row.Description != nil && strings.TrimSpace(*row.Description) != "" { - description = strings.TrimSpace(*row.Description) - break - } - } - } - if err := tx.Where("LOWER(TRIM(partnumber)) = LOWER(TRIM(?))", partnumber).Delete(&models.LotPartnumber{}).Error; err != nil { - return err - } - var descPtr *string - if description != "" { - descPtr = &description - } - return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&models.LotPartnumber{ - Partnumber: partnumber, - LotName: lotName, - Description: descPtr, - }).Error - }) -} - -func (s *StockImportService) DeleteMapping(partnumber string) (int64, error) { - if s.db == nil { - return 0, fmt.Errorf("offline mode: mappings unavailable") - } - partnumber = strings.TrimSpace(partnumber) - if partnumber == "" { - return 0, fmt.Errorf("partnumber is required") - } - res := s.db.Where("LOWER(TRIM(partnumber)) = LOWER(TRIM(?))", partnumber).Delete(&models.LotPartnumber{}) - return res.RowsAffected, res.Error -} - -func (s *StockImportService) ListIgnoreRules(page, perPage int) ([]models.StockIgnoreRule, int64, error) { - if s.db == nil { - return nil, 0, fmt.Errorf("offline mode: ignore rules unavailable") - } - if page < 1 { - page = 1 - } - if perPage < 1 { - perPage = 50 - } - if perPage > 500 { - perPage = 500 - } - - offset := (page - 1) * perPage - query := s.db.Model(&models.StockIgnoreRule{}) - var total int64 - if err := query.Count(&total).Error; err != nil { - return nil, 0, err - } - var rows []models.StockIgnoreRule - if err := query.Order("id DESC").Offset(offset).Limit(perPage).Find(&rows).Error; err != nil { - return nil, 0, err - } - return rows, total, nil -} - -func (s *StockImportService) UpsertIgnoreRule(target, matchType, pattern string) error { - if s.db == nil { - return fmt.Errorf("offline mode: ignore rules unavailable") - } - target = normalizeIgnoreTarget(target) - matchType = normalizeIgnoreMatchType(matchType) - pattern = strings.TrimSpace(pattern) - if target == "" || matchType == "" || pattern == "" { - return fmt.Errorf("target, match_type and pattern are required") - } - res := s.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&models.StockIgnoreRule{ - Target: target, - MatchType: matchType, - Pattern: pattern, - }) - if res.Error != nil { - return res.Error - } - if res.RowsAffected == 0 { - return fmt.Errorf("rule already exists") - } - return nil -} - -func (s *StockImportService) DeleteIgnoreRule(id uint) (int64, error) { - if s.db == nil { - return 0, fmt.Errorf("offline mode: ignore rules unavailable") - } - res := s.db.Delete(&models.StockIgnoreRule{}, id) - return res.RowsAffected, res.Error -} - -func (s *StockImportService) loadIgnoreRules() ([]stockIgnoreRule, error) { - var rows []models.StockIgnoreRule - if err := s.db.Find(&rows).Error; err != nil { - return nil, err - } - rules := make([]stockIgnoreRule, 0, len(rows)) - for _, row := range rows { - target := normalizeIgnoreTarget(row.Target) - matchType := normalizeIgnoreMatchType(row.MatchType) - pattern := normalizeKey(row.Pattern) - if target == "" || matchType == "" || pattern == "" { - continue - } - rules = append(rules, stockIgnoreRule{ - Target: target, - MatchType: matchType, - Pattern: pattern, - }) - } - return rules, nil -} - -func collectSortedSuggestions(src map[string]StockMappingSuggestion, limit int) []StockMappingSuggestion { - if len(src) == 0 { - return nil - } - items := make([]StockMappingSuggestion, 0, len(src)) - for _, item := range src { - items = append(items, item) - } - sort.Slice(items, func(i, j int) bool { - return strings.ToLower(items[i].Partnumber) < strings.ToLower(items[j].Partnumber) - }) - if limit > 0 && len(items) > limit { - return items[:limit] - } - return items -} - -func shouldIgnoreStockRow(row stockImportRow, rules []stockIgnoreRule) bool { - if len(rules) == 0 { - return false - } - partnumber := normalizeKey(row.Article) - description := normalizeKey(row.Description) - for _, rule := range rules { - candidate := "" - if rule.Target == "partnumber" { - candidate = partnumber - } else { - candidate = description - } - if candidate == "" || rule.Pattern == "" { - continue - } - switch rule.MatchType { - case "exact": - if candidate == rule.Pattern { - return true - } - case "prefix": - if strings.HasPrefix(candidate, rule.Pattern) { - return true - } - case "suffix": - if strings.HasSuffix(candidate, rule.Pattern) { - return true - } - } - } - return false -} - -func normalizeIgnoreTarget(v string) string { - switch strings.ToLower(strings.TrimSpace(v)) { - case "partnumber": - return "partnumber" - case "description": - return "description" - default: - return "" - } -} - -func normalizeIgnoreMatchType(v string) string { - switch strings.ToLower(strings.TrimSpace(v)) { - case "exact": - return "exact" - case "prefix": - return "prefix" - case "suffix": - return "suffix" - default: - return "" - } -} - -var ( - reISODate = regexp.MustCompile(`\b(20\d{2})-(\d{2})-(\d{2})\b`) - reRuDate = regexp.MustCompile(`\b([0-3]\d)\.([01]\d)\.(20\d{2})\b`) - mxlCellRe = regexp.MustCompile(`\{16,\d+,\s*\{1,1,\s*\{"ru","(.*?)"\}\s*\},0\},(\d+),`) -) - -func parseStockRows(filename string, content []byte) ([]stockImportRow, error) { - switch strings.ToLower(filepath.Ext(filename)) { - case ".mxl": - return parseMXLRows(content) - case ".xlsx": - return parseXLSXRows(content) - default: - return nil, fmt.Errorf("unsupported file format: %s", filepath.Ext(filename)) - } -} - -func parseMXLRows(content []byte) ([]stockImportRow, error) { - text := string(content) - matches := mxlCellRe.FindAllStringSubmatch(text, -1) - if len(matches) == 0 { - return nil, fmt.Errorf("mxl parsing failed: no cells found") - } - - rows := make([]map[int]string, 0, 128) - current := map[int]string{} - for _, m := range matches { - val := strings.ReplaceAll(m[1], `""`, `"`) - col, err := strconv.Atoi(m[2]) - if err != nil { - continue - } - if col == 1 && len(current) > 0 { - rows = append(rows, current) - current = map[int]string{} - } - current[col] = strings.TrimSpace(val) - } - if len(current) > 0 { - rows = append(rows, current) - } - - result := make([]stockImportRow, 0, len(rows)) - for _, r := range rows { - article := strings.TrimSpace(r[2]) - if article == "" || strings.EqualFold(article, "Артикул") { - continue - } - price, err := parseLocalizedFloat(r[5]) - if err != nil { - continue - } - qtyRaw := strings.TrimSpace(r[6]) - qty, err := parseLocalizedQty(qtyRaw) - if err != nil { - qty = 0 - } - result = append(result, stockImportRow{ - Folder: strings.TrimSpace(r[1]), - Article: article, - Description: strings.TrimSpace(r[3]), - Vendor: strings.TrimSpace(r[4]), - Price: price, - Qty: qty, - QtyRaw: qtyRaw, - QtyInvalid: err != nil, - }) - } - return result, nil -} - -func parseXLSXRows(content []byte) ([]stockImportRow, error) { - zr, err := zip.NewReader(bytes.NewReader(content), int64(len(content))) - if err != nil { - return nil, fmt.Errorf("opening xlsx: %w", err) - } - - sharedStrings, _ := readSharedStrings(zr) - sheetPath := firstWorksheetPath(zr) - if sheetPath == "" { - return nil, fmt.Errorf("xlsx parsing failed: worksheet not found") - } - sheetData, err := readZipFile(zr, sheetPath) - if err != nil { - return nil, err - } - - type xlsxInline struct { - T string `xml:"t"` - } - type xlsxCell struct { - R string `xml:"r,attr"` - T string `xml:"t,attr"` - V string `xml:"v"` - IS *xlsxInline `xml:"is"` - } - type xlsxRow struct { - C []xlsxCell `xml:"c"` - } - type xlsxSheet struct { - Rows []xlsxRow `xml:"sheetData>row"` - } - - var ws xlsxSheet - if err := xml.Unmarshal(sheetData, &ws); err != nil { - return nil, fmt.Errorf("decode worksheet: %w", err) - } - - grid := make([]map[int]string, 0, len(ws.Rows)) - for _, r := range ws.Rows { - rowMap := make(map[int]string, len(r.C)) - for _, c := range r.C { - colIdx := excelRefColumn(c.R) - if colIdx < 0 { - continue - } - inlineText := "" - if c.IS != nil { - inlineText = c.IS.T - } - rowMap[colIdx] = decodeXLSXCell(c.T, c.V, inlineText, sharedStrings) - } - grid = append(grid, rowMap) - } - - headerRow := -1 - headers := map[string]int{} - for i, row := range grid { - for idx, val := range row { - norm := normalizeHeader(val) - switch norm { - case "папка", "артикул", "описание", "вендор", "стоимость", "свободно": - headers[norm] = idx - } - } - _, hasArticle := headers["артикул"] - _, hasPrice := headers["стоимость"] - if hasArticle && hasPrice { - headerRow = i - break - } - } - if headerRow < 0 { - return nil, fmt.Errorf("xlsx parsing failed: header row not found") - } - - result := make([]stockImportRow, 0, len(grid)-headerRow-1) - idxFolder, hasFolder := headers["папка"] - idxArticle := headers["артикул"] - idxDesc, hasDesc := headers["описание"] - idxVendor, hasVendor := headers["вендор"] - idxPrice := headers["стоимость"] - idxQty, hasQty := headers["свободно"] - if !hasQty { - return nil, fmt.Errorf("xlsx parsing failed: qty column 'Свободно' not found") - } - for i := headerRow + 1; i < len(grid); i++ { - row := grid[i] - article := strings.TrimSpace(row[idxArticle]) - if article == "" { - continue - } - price, err := parseLocalizedFloat(row[idxPrice]) - if err != nil { - continue - } - qty := 0.0 - qtyRaw := "" - qtyInvalid := false - if hasQty { - qtyRaw = strings.TrimSpace(row[idxQty]) - qty, err = parseLocalizedQty(qtyRaw) - if err != nil { - qty = 0 - qtyInvalid = true - } - } - - folder := "" - if hasFolder { - folder = strings.TrimSpace(row[idxFolder]) - } - description := "" - if hasDesc { - description = strings.TrimSpace(row[idxDesc]) - } - vendor := "" - if hasVendor { - vendor = strings.TrimSpace(row[idxVendor]) - } - - result = append(result, stockImportRow{ - Folder: folder, - Article: article, - Description: description, - Vendor: vendor, - Price: price, - Qty: qty, - QtyRaw: qtyRaw, - QtyInvalid: qtyInvalid, - }) - } - return result, nil -} - -func parseLocalizedFloat(value string) (float64, error) { - clean := strings.TrimSpace(value) - clean = strings.ReplaceAll(clean, "\u00a0", "") - clean = strings.ReplaceAll(clean, " ", "") - clean = strings.ReplaceAll(clean, ",", ".") - if clean == "" { - return 0, fmt.Errorf("empty number") - } - return strconv.ParseFloat(clean, 64) -} - -func parseLocalizedQty(value string) (float64, error) { - clean := strings.TrimSpace(value) - if clean == "" { - return 0, fmt.Errorf("empty qty") - } - if v, err := parseLocalizedFloat(clean); err == nil { - return v, nil - } - // Tolerate strings like "1 200 шт" by extracting the first numeric token. - re := regexp.MustCompile(`[-+]?\d[\d\s\u00a0]*(?:[.,]\d+)?`) - match := re.FindString(clean) - if strings.TrimSpace(match) == "" { - return 0, fmt.Errorf("invalid qty: %s", value) - } - return parseLocalizedFloat(match) -} - -func detectImportDate(content []byte, filename string, fileModTime time.Time) time.Time { - if d, ok := extractDateFromText(string(content)); ok { - return d - } - if d, ok := extractDateFromFilename(filename); ok { - return d - } - if !fileModTime.IsZero() { - return normalizeDate(fileModTime) - } - return normalizeDate(time.Now()) -} - -func extractDateFromText(text string) (time.Time, bool) { - if m := reISODate.FindStringSubmatch(text); len(m) == 4 { - d, err := time.Parse("2006-01-02", m[0]) - if err == nil { - return normalizeDate(d), true - } - } - if m := reRuDate.FindStringSubmatch(text); len(m) == 4 { - d, err := time.Parse("02.01.2006", m[0]) - if err == nil { - return normalizeDate(d), true - } - } - return time.Time{}, false -} - -func extractDateFromFilename(filename string) (time.Time, bool) { - base := filepath.Base(filename) - if m := reISODate.FindStringSubmatch(base); len(m) == 4 { - d, err := time.Parse("2006-01-02", m[0]) - if err == nil { - return normalizeDate(d), true - } - } - if m := reRuDate.FindStringSubmatch(base); len(m) == 4 { - d, err := time.Parse("02.01.2006", m[0]) - if err == nil { - return normalizeDate(d), true - } - } - return time.Time{}, false -} - -func normalizeDate(t time.Time) time.Time { - y, m, d := t.Date() - return time.Date(y, m, d, 0, 0, 0, 0, time.Local) -} - -func median(values []float64) float64 { - if len(values) == 0 { - return 0 - } - c := append([]float64(nil), values...) - sort.Float64s(c) - n := len(c) - if n%2 == 0 { - return (c[n/2-1] + c[n/2]) / 2 - } - return c[n/2] -} - -func weightedMedian(values []weightedPricePoint) float64 { - if len(values) == 0 { - return 0 - } - - type pair struct { - price float64 - weight float64 - } - items := make([]pair, 0, len(values)) - totalWeight := 0.0 - prices := make([]float64, 0, len(values)) - - for _, v := range values { - if v.price <= 0 { - continue - } - prices = append(prices, v.price) - w := v.weight - if w > 0 { - items = append(items, pair{price: v.price, weight: w}) - totalWeight += w - } - } - - // Fallback for rows without positive weights. - if totalWeight <= 0 { - return median(prices) - } - - sort.Slice(items, func(i, j int) bool { - if items[i].price == items[j].price { - return items[i].weight < items[j].weight - } - return items[i].price < items[j].price - }) - - threshold := totalWeight / 2.0 - acc := 0.0 - for _, it := range items { - acc += it.weight - if acc >= threshold { - return it.price - } - } - return items[len(items)-1].price -} - -func normalizeKey(v string) string { - return lotmatch.NormalizeKey(v) -} - -func readZipFile(zr *zip.Reader, name string) ([]byte, error) { - for _, f := range zr.File { - if f.Name != name { - continue - } - rc, err := f.Open() - if err != nil { - return nil, err - } - defer rc.Close() - return io.ReadAll(rc) - } - return nil, fmt.Errorf("zip entry not found: %s", name) -} - -func firstWorksheetPath(zr *zip.Reader) string { - candidates := make([]string, 0, 4) - for _, f := range zr.File { - if strings.HasPrefix(f.Name, "xl/worksheets/") && strings.HasSuffix(f.Name, ".xml") { - candidates = append(candidates, f.Name) - } - } - if len(candidates) == 0 { - return "" - } - sort.Strings(candidates) - for _, c := range candidates { - if strings.HasSuffix(c, "sheet1.xml") { - return c - } - } - return candidates[0] -} - -func readSharedStrings(zr *zip.Reader) ([]string, error) { - data, err := readZipFile(zr, "xl/sharedStrings.xml") - if err != nil { - return nil, err - } - type richRun struct { - Text string `xml:"t"` - } - type si struct { - Text string `xml:"t"` - Runs []richRun `xml:"r"` - } - type sst struct { - Items []si `xml:"si"` - } - - var parsed sst - if err := xml.Unmarshal(data, &parsed); err != nil { - return nil, err - } - - values := make([]string, 0, len(parsed.Items)) - for _, item := range parsed.Items { - if item.Text != "" { - values = append(values, item.Text) - continue - } - var b strings.Builder - for _, run := range item.Runs { - b.WriteString(run.Text) - } - values = append(values, b.String()) - } - return values, nil -} - -func decodeXLSXCell(cellType, value, inlineText string, sharedStrings []string) string { - switch cellType { - case "s": - idx, err := strconv.Atoi(strings.TrimSpace(value)) - if err == nil && idx >= 0 && idx < len(sharedStrings) { - return strings.TrimSpace(sharedStrings[idx]) - } - case "inlineStr": - return strings.TrimSpace(inlineText) - default: - return strings.TrimSpace(value) - } - return strings.TrimSpace(value) -} - -func excelRefColumn(ref string) int { - if ref == "" { - return -1 - } - var letters []rune - for _, r := range ref { - if r >= 'A' && r <= 'Z' { - letters = append(letters, r) - } else if r >= 'a' && r <= 'z' { - letters = append(letters, r-'a'+'A') - } else { - break - } - } - if len(letters) == 0 { - return -1 - } - col := 0 - for _, r := range letters { - col = col*26 + int(r-'A'+1) - } - return col - 1 -} - -func normalizeHeader(v string) string { - return strings.ToLower(strings.TrimSpace(strings.ReplaceAll(v, "\u00a0", " "))) -} diff --git a/internal/services/stock_import_test.go b/internal/services/stock_import_test.go deleted file mode 100644 index 613ef41..0000000 --- a/internal/services/stock_import_test.go +++ /dev/null @@ -1,384 +0,0 @@ -package services - -import ( - "archive/zip" - "bytes" - "strings" - "testing" - "time" - - "git.mchus.pro/mchus/quoteforge/internal/lotmatch" - "git.mchus.pro/mchus/quoteforge/internal/models" - "github.com/glebarez/sqlite" - "gorm.io/gorm" -) - -func TestParseMXLRows(t *testing.T) { - content := strings.Join([]string{ - `MOXCEL`, - `{16,2,{1,1,{"ru","Папка"}},0},1,`, - `{16,2,{1,1,{"ru","Артикул"}},0},2,`, - `{16,2,{1,1,{"ru","Описание"}},0},3,`, - `{16,2,{1,1,{"ru","Вендор"}},0},4,`, - `{16,2,{1,1,{"ru","Стоимость"}},0},5,`, - `{16,2,{1,1,{"ru","Свободно"}},0},6,`, - `{16,2,{1,1,{"ru","Серверы"}},0},1,`, - `{16,2,{1,1,{"ru","CPU_X"}},0},2,`, - `{16,2,{1,1,{"ru","Процессор"}},0},3,`, - `{16,2,{1,1,{"ru","AMD"}},0},4,`, - `{16,2,{1,1,{"ru","125,50"}},0},5,`, - `{16,2,{1,1,{"ru","10"}},0},6,`, - }, "\n") - - rows, err := parseMXLRows([]byte(content)) - if err != nil { - t.Fatalf("parseMXLRows: %v", err) - } - if len(rows) != 1 { - t.Fatalf("expected 1 row, got %d", len(rows)) - } - if rows[0].Article != "CPU_X" { - t.Fatalf("unexpected article: %s", rows[0].Article) - } - if rows[0].Price != 125.50 { - t.Fatalf("unexpected price: %v", rows[0].Price) - } - if rows[0].Qty != 10 { - t.Fatalf("unexpected qty: %v", rows[0].Qty) - } -} - -func TestParseMXLRows_EmptyQtyMarkedInvalid(t *testing.T) { - content := strings.Join([]string{ - `MOXCEL`, - `{16,2,{1,1,{"ru","Папка"}},0},1,`, - `{16,2,{1,1,{"ru","Артикул"}},0},2,`, - `{16,2,{1,1,{"ru","Описание"}},0},3,`, - `{16,2,{1,1,{"ru","Вендор"}},0},4,`, - `{16,2,{1,1,{"ru","Стоимость"}},0},5,`, - `{16,2,{1,1,{"ru","Свободно"}},0},6,`, - `{16,2,{1,1,{"ru","Серверы"}},0},1,`, - `{16,2,{1,1,{"ru","CPU_X"}},0},2,`, - `{16,2,{1,1,{"ru","Процессор"}},0},3,`, - `{16,2,{1,1,{"ru","AMD"}},0},4,`, - `{16,2,{1,1,{"ru","125,50"}},0},5,`, - `{16,2,{1,1,{"ru",""}},0},6,`, - }, "\n") - - rows, err := parseMXLRows([]byte(content)) - if err != nil { - t.Fatalf("parseMXLRows: %v", err) - } - if len(rows) != 1 { - t.Fatalf("expected 1 row, got %d", len(rows)) - } - if !rows[0].QtyInvalid { - t.Fatalf("expected QtyInvalid=true for empty qty") - } -} - -func TestParseXLSXRows(t *testing.T) { - xlsx := buildMinimalXLSX(t, []string{ - "Папка", "Артикул", "Описание", "Вендор", "Стоимость", "Свободно", - }, []string{ - "Серверы", "CPU_A", "Процессор", "AMD", "99,25", "7", - }) - - rows, err := parseXLSXRows(xlsx) - if err != nil { - t.Fatalf("parseXLSXRows: %v", err) - } - if len(rows) != 1 { - t.Fatalf("expected 1 row, got %d", len(rows)) - } - if rows[0].Article != "CPU_A" { - t.Fatalf("unexpected article: %s", rows[0].Article) - } - if rows[0].Price != 99.25 { - t.Fatalf("unexpected price: %v", rows[0].Price) - } -} - -func TestLotResolverPrecedenceAndConflicts(t *testing.T) { - resolver := lotmatch.NewLotResolver( - []models.LotPartnumber{ - {Partnumber: "pn-1", LotName: "LOT_MAPPED"}, - {Partnumber: "pn-conflict", LotName: "LOT_A"}, - {Partnumber: "pn-conflict", LotName: "LOT_B"}, - }, - []models.Lot{ - {LotName: "CPU_A_LONG"}, - {LotName: "CPU_A"}, - {LotName: "ABC "}, - {LotName: "ABC\t"}, - }, - ) - - lot, typ, err := resolver.Resolve("pn-1") - if err != nil || lot != "LOT_MAPPED" || typ != "mapping_table" { - t.Fatalf("mapping_table mismatch: lot=%s typ=%s err=%v", lot, typ, err) - } - - lot, typ, err = resolver.Resolve("cpu_a") - if err != nil || lot != "CPU_A" || typ != "article_exact" { - t.Fatalf("article_exact mismatch: lot=%s typ=%s err=%v", lot, typ, err) - } - - lot, typ, err = resolver.Resolve("cpu_a_long_suffix") - if err != nil || lot != "CPU_A_LONG" || typ != "prefix" { - t.Fatalf("prefix mismatch: lot=%s typ=%s err=%v", lot, typ, err) - } - - _, _, err = resolver.Resolve("abx") - if err == nil || err != lotmatch.ErrResolveNotFound { - t.Fatalf("expected not found error, got %v", err) - } - - _, _, err = resolver.Resolve("pn-conflict") - if err == nil || err != lotmatch.ErrResolveConflict { - t.Fatalf("expected conflict, got %v", err) - } -} - -func TestImportNoValidRowsKeepsStockLog(t *testing.T) { - db := openTestDB(t) - if err := db.AutoMigrate(&models.StockLog{}); err != nil { - t.Fatalf("automigrate stock_log: %v", err) - } - - existing := models.StockLog{ - Partnumber: "CPU_A", - Date: time.Now(), - Price: 10, - } - if err := db.Create(&existing).Error; err != nil { - t.Fatalf("seed stock_log: %v", err) - } - - svc := NewStockImportService(db, nil) - headerOnly := []byte(strings.Join([]string{ - `MOXCEL`, - `{16,2,{1,1,{"ru","Папка"}},0},1,`, - `{16,2,{1,1,{"ru","Артикул"}},0},2,`, - `{16,2,{1,1,{"ru","Описание"}},0},3,`, - `{16,2,{1,1,{"ru","Вендор"}},0},4,`, - `{16,2,{1,1,{"ru","Стоимость"}},0},5,`, - `{16,2,{1,1,{"ru","Свободно"}},0},6,`, - }, "\n")) - - if _, err := svc.Import("test.mxl", headerOnly, time.Now(), "tester", nil); err == nil { - t.Fatalf("expected import error") - } - - var count int64 - if err := db.Model(&models.StockLog{}).Count(&count).Error; err != nil { - t.Fatalf("count stock_log: %v", err) - } - if count != 1 { - t.Fatalf("expected stock_log unchanged, got %d rows", count) - } -} - -func TestReplaceStockLogs(t *testing.T) { - db := openTestDB(t) - if err := db.AutoMigrate(&models.StockLog{}); err != nil { - t.Fatalf("automigrate stock_log: %v", err) - } - - if err := db.Create(&models.StockLog{Partnumber: "OLD", Date: time.Now(), Price: 1}).Error; err != nil { - t.Fatalf("seed old row: %v", err) - } - - svc := NewStockImportService(db, nil) - records := []models.StockLog{ - {Partnumber: "NEW_1", Date: time.Now(), Price: 2}, - {Partnumber: "NEW_2", Date: time.Now(), Price: 3}, - } - - deleted, inserted, err := svc.replaceStockLogs(records) - if err != nil { - t.Fatalf("replaceStockLogs: %v", err) - } - if deleted != 1 || inserted != 2 { - t.Fatalf("unexpected replace stats deleted=%d inserted=%d", deleted, inserted) - } - - var rows []models.StockLog - if err := db.Order("partnumber").Find(&rows).Error; err != nil { - t.Fatalf("read rows: %v", err) - } - if len(rows) != 2 || rows[0].Partnumber != "NEW_1" || rows[1].Partnumber != "NEW_2" { - t.Fatalf("unexpected rows after replace: %#v", rows) - } -} - -func TestWeightedMedian(t *testing.T) { - got := weightedMedian([]weightedPricePoint{ - {price: 10, weight: 1}, - {price: 20, weight: 3}, - {price: 50, weight: 1}, - }) - if got != 20 { - t.Fatalf("expected weighted median 20, got %v", got) - } -} - -func TestWeightedMedianFallbackToMedianWhenNoWeights(t *testing.T) { - got := weightedMedian([]weightedPricePoint{ - {price: 10, weight: 0}, - {price: 20, weight: 0}, - {price: 30, weight: 0}, - }) - if got != 20 { - t.Fatalf("expected fallback median 20, got %v", got) - } -} - -func TestBuildWarehousePricelistItems_UsesPrefixResolver(t *testing.T) { - db := openTestDB(t) - if err := db.AutoMigrate(&models.StockLog{}, &models.Lot{}, &models.LotPartnumber{}); err != nil { - t.Fatalf("automigrate: %v", err) - } - - if err := db.Create(&models.Lot{LotName: "CPU_A"}).Error; err != nil { - t.Fatalf("seed lot: %v", err) - } - - qty1 := 3.0 - qty2 := 1.0 - now := time.Now() - rows := []models.StockLog{ - {Partnumber: "CPU_A-001", Date: now, Price: 100, Qty: &qty1}, - {Partnumber: "CPU_A-XYZ", Date: now, Price: 120, Qty: &qty2}, - } - if err := db.Create(&rows).Error; err != nil { - t.Fatalf("seed stock_log: %v", err) - } - - svc := NewStockImportService(db, nil) - items, err := svc.buildWarehousePricelistItems() - if err != nil { - t.Fatalf("buildWarehousePricelistItems: %v", err) - } - if len(items) != 1 { - t.Fatalf("expected 1 item, got %d", len(items)) - } - if items[0].LotName != "CPU_A" { - t.Fatalf("expected lot CPU_A, got %s", items[0].LotName) - } - if items[0].Price != 100 { - t.Fatalf("expected weighted median 100, got %v", items[0].Price) - } -} - -func TestPartnumberMappings_WildcardMatch(t *testing.T) { - db := openTestDB(t) - if err := db.AutoMigrate(&models.LotPartnumber{}, &models.Lot{}); err != nil { - t.Fatalf("automigrate: %v", err) - } - - mappings := []models.LotPartnumber{ - {Partnumber: "R750*", LotName: "SERVER_R750"}, - {Partnumber: "HDD-01", LotName: "HDD_01"}, - } - if err := db.Create(&mappings).Error; err != nil { - t.Fatalf("seed mappings: %v", err) - } - if err := db.Create(&models.Lot{LotName: "MEM_DDR5_16G_4800"}).Error; err != nil { - t.Fatalf("seed lot: %v", err) - } - - resolver, err := lotmatch.NewMappingMatcherFromDB(db) - if err != nil { - t.Fatalf("NewMappingMatcherFromDB: %v", err) - } - - if got := resolver.MatchLots("R750XD"); len(got) != 1 || got[0] != "SERVER_R750" { - t.Fatalf("expected wildcard match SERVER_R750, got %#v", got) - } - if got := resolver.MatchLots("HDD-01"); len(got) != 1 || got[0] != "HDD_01" { - t.Fatalf("expected exact match HDD_01, got %#v", got) - } - if got := resolver.MatchLots("UNKNOWN"); len(got) != 0 { - t.Fatalf("expected no matches, got %#v", got) - } - if got := resolver.MatchLots("MEM_DDR5_16G_4800"); len(got) != 1 || got[0] != "MEM_DDR5_16G_4800" { - t.Fatalf("expected exact lot fallback, got %#v", got) - } -} - -func openTestDB(t *testing.T) *gorm.DB { - t.Helper() - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - return db -} - -func buildMinimalXLSX(t *testing.T, headers, values []string) []byte { - t.Helper() - var buf bytes.Buffer - zw := zip.NewWriter(&buf) - - write := func(name, body string) { - w, err := zw.Create(name) - if err != nil { - t.Fatalf("create zip entry %s: %v", name, err) - } - if _, err := w.Write([]byte(body)); err != nil { - t.Fatalf("write zip entry %s: %v", name, err) - } - } - - write("[Content_Types].xml", ` - - - - - -`) - write("_rels/.rels", ` - - -`) - write("xl/workbook.xml", ` - - - - -`) - write("xl/_rels/workbook.xml.rels", ` - - -`) - - makeCell := func(ref, value string) string { - escaped := strings.ReplaceAll(value, "&", "&") - escaped = strings.ReplaceAll(escaped, "<", "<") - escaped = strings.ReplaceAll(escaped, ">", ">") - return `` + escaped + `` - } - - cols := []string{"A", "B", "C", "D", "E", "F"} - var headerCells, valueCells strings.Builder - for i := 0; i < len(cols) && i < len(headers); i++ { - headerCells.WriteString(makeCell(cols[i]+"1", headers[i])) - } - for i := 0; i < len(cols) && i < len(values); i++ { - valueCells.WriteString(makeCell(cols[i]+"2", values[i])) - } - - write("xl/worksheets/sheet1.xml", ` - - - `+headerCells.String()+` - `+valueCells.String()+` - -`) - - if err := zw.Close(); err != nil { - t.Fatalf("close zip: %v", err) - } - return buf.Bytes() -} diff --git a/internal/services/sync/worker.go b/internal/services/sync/worker.go index f874617..599659b 100644 --- a/internal/services/sync/worker.go +++ b/internal/services/sync/worker.go @@ -71,6 +71,15 @@ func (w *Worker) runSync() { return } + if readiness, err := w.service.EnsureReadinessForSync(); err != nil { + w.logger.Warn("background sync: blocked by readiness guard", + "error", err, + "reason_code", readiness.ReasonCode, + "reason_text", readiness.ReasonText, + ) + return + } + // Push pending changes first pushed, err := w.service.PushPendingChanges() if err != nil { diff --git a/internal/warehouse/snapshot.go b/internal/warehouse/snapshot.go deleted file mode 100644 index 5c4a4c9..0000000 --- a/internal/warehouse/snapshot.go +++ /dev/null @@ -1,219 +0,0 @@ -package warehouse - -import ( - "sort" - "strings" - - "git.mchus.pro/mchus/quoteforge/internal/lotmatch" - "git.mchus.pro/mchus/quoteforge/internal/models" - "gorm.io/gorm" -) - -type SnapshotItem struct { - LotName string - Price float64 - PriceMethod string -} - -type weightedPricePoint struct { - price float64 - weight float64 -} - -// ComputePricelistItemsFromStockLog builds warehouse snapshot items from stock_log. -func ComputePricelistItemsFromStockLog(db *gorm.DB) ([]SnapshotItem, error) { - type stockRow struct { - Partnumber string `gorm:"column:partnumber"` - Price float64 `gorm:"column:price"` - Qty *float64 `gorm:"column:qty"` - } - - var rows []stockRow - if err := db.Table(models.StockLog{}.TableName()).Select("partnumber, price, qty").Where("price > 0").Scan(&rows).Error; err != nil { - return nil, err - } - - resolver, err := lotmatch.NewLotResolverFromDB(db) - if err != nil { - return nil, err - } - - grouped := make(map[string][]weightedPricePoint) - for _, row := range rows { - pn := strings.TrimSpace(row.Partnumber) - if pn == "" || row.Price <= 0 { - continue - } - lot, _, err := resolver.Resolve(pn) - if err != nil || strings.TrimSpace(lot) == "" { - continue - } - weight := 0.0 - if row.Qty != nil && *row.Qty > 0 { - weight = *row.Qty - } - grouped[lot] = append(grouped[lot], weightedPricePoint{price: row.Price, weight: weight}) - } - - items := make([]SnapshotItem, 0, len(grouped)) - for lot, values := range grouped { - price := weightedMedian(values) - if price <= 0 { - continue - } - items = append(items, SnapshotItem{LotName: lot, Price: price, PriceMethod: "weighted_median"}) - } - sort.Slice(items, func(i, j int) bool { return items[i].LotName < items[j].LotName }) - return items, nil -} - -// LoadLotMetrics returns stock qty and partnumbers for selected lots. -// If latestOnly is true, qty/partnumbers from stock_log are calculated only for latest import date. -func LoadLotMetrics(db *gorm.DB, lotNames []string, latestOnly bool) (map[string]float64, map[string][]string, error) { - qtyByLot := make(map[string]float64, len(lotNames)) - partnumbersByLot := make(map[string][]string, len(lotNames)) - if len(lotNames) == 0 { - return qtyByLot, partnumbersByLot, nil - } - - lotSet := make(map[string]struct{}, len(lotNames)) - for _, lot := range lotNames { - trimmed := strings.TrimSpace(lot) - if trimmed == "" { - continue - } - lotSet[trimmed] = struct{}{} - } - - resolver, err := lotmatch.NewLotResolverFromDB(db) - if err != nil { - return nil, nil, err - } - - seenPN := make(map[string]map[string]struct{}, len(lotSet)) - addPartnumber := func(lotName, partnumber string) { - lotName = strings.TrimSpace(lotName) - partnumber = strings.TrimSpace(partnumber) - if lotName == "" || partnumber == "" { - return - } - if _, ok := lotSet[lotName]; !ok { - return - } - if _, ok := seenPN[lotName]; !ok { - seenPN[lotName] = map[string]struct{}{} - } - key := strings.ToLower(partnumber) - if _, ok := seenPN[lotName][key]; ok { - return - } - seenPN[lotName][key] = struct{}{} - partnumbersByLot[lotName] = append(partnumbersByLot[lotName], partnumber) - } - - var mappingRows []models.LotPartnumber - if err := db.Select("partnumber, lot_name").Find(&mappingRows).Error; err != nil { - return nil, nil, err - } - for _, row := range mappingRows { - addPartnumber(row.LotName, row.Partnumber) - } - - type stockRow struct { - Partnumber string `gorm:"column:partnumber"` - Qty *float64 `gorm:"column:qty"` - } - var stockRows []stockRow - if latestOnly { - err = db.Raw(` - SELECT sl.partnumber, sl.qty - FROM stock_log sl - INNER JOIN (SELECT MAX(date) AS max_date FROM stock_log) md ON sl.date = md.max_date - `).Scan(&stockRows).Error - } else { - err = db.Table(models.StockLog{}.TableName()).Select("partnumber, qty").Scan(&stockRows).Error - } - if err != nil { - return nil, nil, err - } - - for _, row := range stockRows { - pn := strings.TrimSpace(row.Partnumber) - if pn == "" { - continue - } - lot, _, err := resolver.Resolve(pn) - if err != nil { - continue - } - if _, exists := lotSet[lot]; !exists { - continue - } - if row.Qty != nil { - qtyByLot[lot] += *row.Qty - } - addPartnumber(lot, pn) - } - - for lot := range partnumbersByLot { - sort.Slice(partnumbersByLot[lot], func(i, j int) bool { - return strings.ToLower(partnumbersByLot[lot][i]) < strings.ToLower(partnumbersByLot[lot][j]) - }) - } - - return qtyByLot, partnumbersByLot, nil -} - -func weightedMedian(values []weightedPricePoint) float64 { - if len(values) == 0 { - return 0 - } - type pair struct { - price float64 - weight float64 - } - items := make([]pair, 0, len(values)) - totalWeight := 0.0 - prices := make([]float64, 0, len(values)) - for _, v := range values { - if v.price <= 0 { - continue - } - prices = append(prices, v.price) - if v.weight > 0 { - items = append(items, pair{price: v.price, weight: v.weight}) - totalWeight += v.weight - } - } - if totalWeight <= 0 { - return median(prices) - } - sort.Slice(items, func(i, j int) bool { - if items[i].price == items[j].price { - return items[i].weight < items[j].weight - } - return items[i].price < items[j].price - }) - threshold := totalWeight / 2.0 - acc := 0.0 - for _, it := range items { - acc += it.weight - if acc >= threshold { - return it.price - } - } - return items[len(items)-1].price -} - -func median(values []float64) float64 { - if len(values) == 0 { - return 0 - } - cp := append([]float64(nil), values...) - sort.Float64s(cp) - n := len(cp) - if n%2 == 0 { - return (cp[n/2-1] + cp[n/2]) / 2 - } - return cp[n/2] -} diff --git a/internal/warehouse/snapshot_test.go b/internal/warehouse/snapshot_test.go deleted file mode 100644 index 0fca96c..0000000 --- a/internal/warehouse/snapshot_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package warehouse - -import ( - "math" - "slices" - "testing" - "time" - - "git.mchus.pro/mchus/quoteforge/internal/models" - "github.com/glebarez/sqlite" - "gorm.io/gorm" -) - -func TestComputePricelistItemsFromStockLog(t *testing.T) { - db := openTestDB(t) - if err := db.AutoMigrate(&models.Lot{}, &models.LotPartnumber{}, &models.StockLog{}); err != nil { - t.Fatalf("automigrate: %v", err) - } - - if err := db.Create(&models.Lot{LotName: "CPU_X"}).Error; err != nil { - t.Fatalf("seed lot: %v", err) - } - if err := db.Create(&models.LotPartnumber{Partnumber: "PN-CPU-X", LotName: "CPU_X"}).Error; err != nil { - t.Fatalf("seed mapping: %v", err) - } - - qtySmall := 1.0 - qtyBig := 9.0 - now := time.Now() - rows := []models.StockLog{ - {Partnumber: "PN CPU X", Date: now, Price: 100, Qty: &qtySmall}, - {Partnumber: "CPU_X-EXTRA", Date: now, Price: 200, Qty: &qtyBig}, - } - if err := db.Create(&rows).Error; err != nil { - t.Fatalf("seed stock rows: %v", err) - } - - items, err := ComputePricelistItemsFromStockLog(db) - if err != nil { - t.Fatalf("ComputePricelistItemsFromStockLog: %v", err) - } - if len(items) != 1 { - t.Fatalf("expected 1 item, got %d", len(items)) - } - if items[0].LotName != "CPU_X" { - t.Fatalf("expected lot CPU_X, got %s", items[0].LotName) - } - if math.Abs(items[0].Price-200) > 0.001 { - t.Fatalf("expected weighted median 200, got %f", items[0].Price) - } -} - -func TestLoadLotMetricsLatestOnlyIncludesPartnumbers(t *testing.T) { - db := openTestDB(t) - if err := db.AutoMigrate(&models.Lot{}, &models.LotPartnumber{}, &models.StockLog{}); err != nil { - t.Fatalf("automigrate: %v", err) - } - - if err := db.Create(&models.Lot{LotName: "CPU_X"}).Error; err != nil { - t.Fatalf("seed lot: %v", err) - } - if err := db.Create(&models.LotPartnumber{Partnumber: "PN-MAPPED", LotName: "CPU_X"}).Error; err != nil { - t.Fatalf("seed mapping: %v", err) - } - - oldDate := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) - newDate := time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC) - oldQty := 10.0 - newQty := 3.0 - rows := []models.StockLog{ - {Partnumber: "CPU_X-001", Date: oldDate, Price: 100, Qty: &oldQty}, - {Partnumber: "CPU_X-001", Date: newDate, Price: 100, Qty: &newQty}, - } - if err := db.Create(&rows).Error; err != nil { - t.Fatalf("seed stock rows: %v", err) - } - - qtyByLot, pnsByLot, err := LoadLotMetrics(db, []string{"CPU_X"}, true) - if err != nil { - t.Fatalf("LoadLotMetrics: %v", err) - } - - if got := qtyByLot["CPU_X"]; math.Abs(got-3.0) > 0.001 { - t.Fatalf("expected latest qty 3, got %f", got) - } - - pns := pnsByLot["CPU_X"] - if !slices.Contains(pns, "PN-MAPPED") { - t.Fatalf("expected mapped PN-MAPPED in partnumbers, got %v", pns) - } - if !slices.Contains(pns, "CPU_X-001") { - t.Fatalf("expected stock CPU_X-001 in partnumbers, got %v", pns) - } -} - -func openTestDB(t *testing.T) *gorm.DB { - t.Helper() - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - return db -} diff --git a/scripts/release.sh b/scripts/release.sh index 6c7d373..be8c5e0 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -25,6 +25,25 @@ echo "" RELEASE_DIR="releases/${VERSION}" mkdir -p "${RELEASE_DIR}" +# Create release notes template (always include macOS Gatekeeper note) +if [ ! -f "${RELEASE_DIR}/RELEASE_NOTES.md" ]; then +cat > "${RELEASE_DIR}/RELEASE_NOTES.md" < -

Управление ценами

- -
-
-
- - - - - - - -
- -
- - - - - - - -
-
Загрузка...
-
- - - - - - - - - - - - -
- - - - - - -{{end}} - -{{template "base" .}} diff --git a/web/templates/base.html b/web/templates/base.html index 50e95df..07729d5 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -20,7 +20,7 @@ QuoteForge @@ -81,6 +81,14 @@ + +

Статистика

@@ -176,6 +184,26 @@ document.getElementById('modal-last-sync').textContent = '—'; } + const readinessSection = document.getElementById('modal-readiness-section'); + const readinessReason = document.getElementById('modal-readiness-reason'); + const readinessMinVersion = document.getElementById('modal-readiness-min-version'); + if (data.readiness && data.readiness.blocked) { + readinessSection.classList.remove('hidden'); + readinessReason.textContent = data.readiness.reason_text || 'Синхронизация заблокирована preflight-проверкой.'; + if (data.readiness.required_min_app_version) { + readinessMinVersion.classList.remove('hidden'); + readinessMinVersion.textContent = 'Требуется обновление до версии ' + data.readiness.required_min_app_version; + } else { + readinessMinVersion.classList.add('hidden'); + readinessMinVersion.textContent = ''; + } + } else { + readinessSection.classList.add('hidden'); + readinessReason.textContent = ''; + readinessMinVersion.classList.add('hidden'); + readinessMinVersion.textContent = ''; + } + // Section 2: Statistics document.getElementById('modal-lot-count').textContent = data.is_online ? data.lot_count.toLocaleString() : '—'; document.getElementById('modal-lotlog-count').textContent = data.is_online ? data.lot_log_count.toLocaleString() : '—'; @@ -257,6 +285,11 @@ showToast(successMessage, 'success'); // Update last sync time - removed since dropdown is gone // loadLastSyncTime(); + } else if (resp.status === 423) { + const reason = data.reason_text || data.error || 'Синхронизация заблокирована.'; + showToast(reason, 'error'); + openSyncModal(); + loadSyncInfo(); } else { showToast('Ошибка: ' + (data.error || 'неизвестная ошибка'), 'error'); } diff --git a/web/templates/partials/sync_status.html b/web/templates/partials/sync_status.html index 9538b13..3918fda 100644 --- a/web/templates/partials/sync_status.html +++ b/web/templates/partials/sync_status.html @@ -14,7 +14,14 @@ {{end}} - {{if gt .PendingCount 0}} + {{if .IsBlocked}} + + + + + ! + + {{else if gt .PendingCount 0}}