diff --git a/.gitignore b/.gitignore index afc9b95..9074675 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,12 @@ # QuoteForge config.yaml +# Local SQLite database (contains encrypted credentials) +/data/*.db +/data/*.db-journal +/data/*.db-shm +/data/*.db-wal + # Binaries /server /importer diff --git a/CLAUDE.md b/CLAUDE.md index 116f21b..9b9109e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,638 +1,150 @@ # QuoteForge - Claude Code Instructions -## Project Overview - -QuoteForge — корпоративный инструмент для конфигурирования серверов и формирования коммерческих предложений (КП). Приложение работает с серверной базой данных MariaDB (RFQ_LOG) и локальной SQLite для оффлайн-работы. +## Overview +Корпоративный конфигуратор серверов и формирование КП. MariaDB (RFQ_LOG) + SQLite для оффлайн. ## Development Phases -### Phase 1: Pricelists in MariaDB -- Настройка подключения к БД при первом запуске -- Таблицы qt_pricelists и qt_pricelist_items -- CRUD операции для прайслистов (при наличии прав записи) +### Phase 1: Pricelists in MariaDB ✅ DONE +### Phase 2: Local SQLite Database ✅ DONE -### Phase 2: Projects and Specifications -- Таблицы qt_projects и qt_specifications -- Замена qt_configurations на новую структуру -- Поля: opty, customer_requirement, variant, qty, rev +### Phase 2.5: Full Offline Mode 🔶 IN PROGRESS +**Local-first architecture:** приложение ВСЕГДА работает с SQLite, MariaDB только для синхронизации. -### Phase 3: Local SQLite Database -- Локальное хранение настроек подключения -- Кэширование прайслистов -- Локальные проекты и спецификации -- Синхронизация с сервером +**Принцип работы:** +- ВСЕ операции (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 -- Привязка спецификаций к версиям прайслистов -- Актуализация прайслистов с показом разницы цен -- Автоочистка старых прайслистов (>1 года, usage_count=0) +- 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 -- **Language:** Go 1.22+ -- **Web Framework:** Gin (github.com/gin-gonic/gin) -- **ORM:** GORM (gorm.io/gorm) -- **Server Database:** MariaDB 11 (existing database RFQ_LOG) -- **Local Database:** SQLite (github.com/glebarez/sqlite for pure Go) -- **Frontend:** HTML templates + htmx + Tailwind CSS (CDN) -- **Excel Export:** excelize (github.com/xuri/excelize/v2) +## Key Tables -## Project Structure +### READ-ONLY (external systems) +- `lot` (lot_name PK, lot_description) +- `lot_log` (lot, supplier, date, price, quality, comments) +- `supplier` (supplier_name PK) -``` -quoteforge/ -├── cmd/ -│ ├── server/main.go # Main HTTP server -│ ├── importer/main.go # Import metadata from lot table -│ └── cron/main.go # Cron jobs -├── internal/ -│ ├── config/ -│ │ └── config.go # Load settings from SQLite -│ ├── db/ -│ │ ├── mariadb.go # Server DB connection -│ │ └── sqlite.go # Local DB connection -│ ├── models/ -│ │ ├── lot.go # Existing lot tables -│ │ ├── pricelist.go # Pricelists -│ │ ├── project.go # Projects -│ │ ├── specification.go # Specifications -│ │ └── local_models.go # SQLite models -│ ├── handlers/ -│ │ ├── setup_handler.go # Initial DB setup -│ │ ├── pricelist_handler.go # Pricelist CRUD -│ │ ├── project_handler.go # Project CRUD -│ │ ├── spec_handler.go # Specification CRUD -│ │ └── sync_handler.go # Sync operations -│ ├── services/ -│ │ ├── pricelist_service.go # Pricelist business logic -│ │ ├── project_service.go # Project business logic -│ │ ├── sync_service.go # Sync with server -│ │ └── price_service.go # Price calculations -│ ├── middleware/ -│ │ └── db_check.go # Check DB connection -│ └── repository/ -│ ├── mariadb_repo.go # Server DB queries -│ └── sqlite_repo.go # Local DB queries -├── web/ -│ ├── templates/ -│ │ ├── setup.html # DB connection setup -│ │ ├── projects.html # Project list -│ │ ├── project_detail.html # Project with specs -│ │ ├── spec_editor.html # Specification editor -│ │ └── pricelists.html # Pricelist management -│ └── static/ -├── data/ # SQLite database location -│ └── quoteforge.db -├── migrations/ -└── go.mod -``` +### 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) -## Existing Database Tables (READ-ONLY - DO NOT MODIFY) +### 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) -These tables are used by other systems. Our app only reads from them: +## Business Logic -```sql --- Component catalog -CREATE TABLE lot ( - lot_name CHAR(255) PRIMARY KEY, - lot_description VARCHAR(10000) -); +**Part number parsing:** `CPU_AMD_9654` → category=`CPU`, model=`AMD_9654` --- Price history from suppliers -CREATE TABLE lot_log ( - lot_log_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - lot CHAR(255) NOT NULL, - supplier CHAR(255) NOT NULL, - date DATE NOT NULL, - price DOUBLE NOT NULL, - quality CHAR(255), - comments VARCHAR(15000), - FOREIGN KEY (lot) REFERENCES lot(lot_name), - FOREIGN KEY (supplier) REFERENCES supplier(supplier_name) -); +**Price methods:** manual | median | average | weighted_median --- Supplier catalog -CREATE TABLE supplier ( - supplier_name CHAR(255) PRIMARY KEY, - supplier_comment VARCHAR(10000) -); -``` +**Price freshness:** fresh (<30d, ≥3 quotes) | normal (<60d) | stale (<90d) | critical -## New MariaDB Tables (prefix qt_) - -### Core Tables - -```sql --- Component metadata (extends lot table) -CREATE TABLE qt_lot_metadata ( - lot_name CHAR(255) PRIMARY KEY, - category_id INT, - model VARCHAR(100), - specs JSON, - current_price DECIMAL(12,2), - price_method ENUM('manual', 'median', 'average', 'weighted_median') DEFAULT 'median', - price_period_days INT DEFAULT 90, - price_updated_at TIMESTAMP, - request_count INT DEFAULT 0, - last_request_date DATE, - popularity_score DECIMAL(10,4), - FOREIGN KEY (lot_name) REFERENCES lot(lot_name) -); - --- Categories -CREATE TABLE qt_categories ( - id INT AUTO_INCREMENT PRIMARY KEY, - code VARCHAR(20) UNIQUE NOT NULL, - name VARCHAR(100) NOT NULL, - name_ru VARCHAR(100), - display_order INT DEFAULT 0, - is_required BOOLEAN DEFAULT FALSE -); - --- Usage statistics -CREATE TABLE qt_component_usage_stats ( - lot_name CHAR(255) PRIMARY KEY, - quotes_total INT DEFAULT 0, - quotes_last_30d INT DEFAULT 0, - quotes_last_7d INT DEFAULT 0, - total_quantity INT DEFAULT 0, - total_revenue DECIMAL(14,2) DEFAULT 0, - trend_direction ENUM('up', 'stable', 'down') DEFAULT 'stable', - trend_percent DECIMAL(5,2) DEFAULT 0, - last_used_at TIMESTAMP, - config_count INT DEFAULT 0 -- Number of configurations using this component -); -``` - -### Pricelist Tables - -```sql --- Pricelists (versioned price snapshots) -CREATE TABLE qt_pricelists ( - id INT AUTO_INCREMENT PRIMARY KEY, - version VARCHAR(20) NOT NULL, -- Format: "YYYY-MM-DD-NNN" (e.g., "2024-01-31-001") - name VARCHAR(200), -- Optional display name - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - created_by VARCHAR(100), -- Username of creator - is_active BOOLEAN DEFAULT TRUE, - usage_count INT DEFAULT 0, -- How many specifications use this pricelist - expires_at DATE, -- Auto-calculated: created_at + 1 year - UNIQUE KEY (version) -); - --- Pricelist items -CREATE TABLE qt_pricelist_items ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - pricelist_id INT NOT NULL, - lot_name CHAR(255) NOT NULL, - price DECIMAL(12,2) NOT NULL, - price_method ENUM('manual', 'median', 'average', 'weighted_median'), - FOREIGN KEY (pricelist_id) REFERENCES qt_pricelists(id) ON DELETE CASCADE, - FOREIGN KEY (lot_name) REFERENCES lot(lot_name), - INDEX idx_pricelist_lot (pricelist_id, lot_name) -); -``` - -### Project Tables - -```sql --- Projects (group of specifications) -CREATE TABLE qt_projects ( - id INT AUTO_INCREMENT PRIMARY KEY, - uuid VARCHAR(36) UNIQUE NOT NULL, - opty VARCHAR(50), -- Opportunity/project number - customer_requirement TEXT, -- Link to customer requirements/TZ - name VARCHAR(200) NOT NULL, - notes TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP -); - --- Specifications (replaces qt_configurations) -CREATE TABLE qt_specifications ( - id INT AUTO_INCREMENT PRIMARY KEY, - uuid VARCHAR(36) UNIQUE NOT NULL, - project_id INT NOT NULL, - pricelist_id INT NOT NULL, -- Bound to specific pricelist version - variant VARCHAR(50) NOT NULL, -- Calculation variant (A, B, C, Base, Extended...) - rev INT DEFAULT 1, -- Revision number - qty INT DEFAULT 1, -- Number of servers - items JSON NOT NULL, -- [{"lot_name": "CPU_AMD_9654", "quantity": 2, "unit_price": 11500}] - total_price DECIMAL(12,2), - custom_price DECIMAL(12,2), -- User-defined target price - notes TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - FOREIGN KEY (project_id) REFERENCES qt_projects(id) ON DELETE CASCADE, - FOREIGN KEY (pricelist_id) REFERENCES qt_pricelists(id), - UNIQUE KEY (project_id, variant, rev) -); -``` - -### Legacy Tables (will be deprecated) - -```sql --- Users (RBAC disabled in Phase 1-3) -CREATE TABLE qt_users ( - id INT AUTO_INCREMENT PRIMARY KEY, - username VARCHAR(100) UNIQUE NOT NULL, - email VARCHAR(255) UNIQUE NOT NULL, - password_hash VARCHAR(255) NOT NULL, - role ENUM('viewer', 'editor', 'pricing_admin', 'admin') DEFAULT 'viewer', - is_active BOOLEAN DEFAULT TRUE, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP -); - --- Price overrides (for future use) -CREATE TABLE qt_price_overrides ( - id INT AUTO_INCREMENT PRIMARY KEY, - lot_name CHAR(255) NOT NULL, - price DECIMAL(12,2) NOT NULL, - valid_from DATE NOT NULL, - valid_until DATE, - reason TEXT, - created_by INT NOT NULL, - FOREIGN KEY (lot_name) REFERENCES lot(lot_name) -); - --- Alerts (for future use) -CREATE TABLE qt_pricing_alerts ( - id INT AUTO_INCREMENT PRIMARY KEY, - lot_name CHAR(255) NOT NULL, - alert_type ENUM('high_demand_stale_price', 'price_spike', 'price_drop', 'no_recent_quotes', 'trending_no_price') NOT NULL, - severity ENUM('low', 'medium', 'high', 'critical') DEFAULT 'medium', - message TEXT NOT NULL, - details JSON, - status ENUM('new', 'acknowledged', 'resolved', 'ignored') DEFAULT 'new', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); -``` - -## Local SQLite Database - -Located at `data/quoteforge.db`: - -```sql --- Application settings (connection credentials stored encrypted) -CREATE TABLE app_settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - updated_at TEXT DEFAULT CURRENT_TIMESTAMP -); --- Keys: db_host, db_port, db_name, db_user, db_password (encrypted), last_sync - --- Cached pricelists from server -CREATE TABLE local_pricelists ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - server_id INT NOT NULL, -- ID on MariaDB server - version TEXT NOT NULL UNIQUE, - name TEXT, - created_at TEXT, - synced_at TEXT DEFAULT CURRENT_TIMESTAMP, - is_used INTEGER DEFAULT 0 -- 1 if used by any specification -); - -CREATE TABLE local_pricelist_items ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - pricelist_id INTEGER NOT NULL, - lot_name TEXT NOT NULL, - price REAL NOT NULL, - FOREIGN KEY (pricelist_id) REFERENCES local_pricelists(id) ON DELETE CASCADE -); - --- Local projects (can be synced to server) -CREATE TABLE local_projects ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - uuid TEXT UNIQUE NOT NULL, - server_id INTEGER, -- NULL if not synced yet - opty TEXT, - customer_requirement TEXT, - name TEXT NOT NULL, - notes TEXT, - created_at TEXT DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT DEFAULT CURRENT_TIMESTAMP, - synced_at TEXT, -- NULL if has local changes - sync_status TEXT DEFAULT 'local' -- 'local', 'synced', 'modified' -); - --- Local specifications -CREATE TABLE local_specifications ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - uuid TEXT UNIQUE NOT NULL, - server_id INTEGER, - project_id INTEGER NOT NULL, - pricelist_id INTEGER NOT NULL, - variant TEXT NOT NULL, - rev INTEGER DEFAULT 1, - qty INTEGER DEFAULT 1, - items TEXT NOT NULL, -- JSON string - total_price REAL, - custom_price REAL, - notes TEXT, - created_at TEXT DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT DEFAULT CURRENT_TIMESTAMP, - synced_at TEXT, - sync_status TEXT DEFAULT 'local', - FOREIGN KEY (project_id) REFERENCES local_projects(id) ON DELETE CASCADE, - FOREIGN KEY (pricelist_id) REFERENCES local_pricelists(id) -); - --- Component cache (for offline search) -CREATE TABLE local_components ( - lot_name TEXT PRIMARY KEY, - lot_description TEXT, - category TEXT, - model TEXT, - synced_at TEXT DEFAULT CURRENT_TIMESTAMP -); -``` - -## Key Business Logic - -### 1. Database Connection Setup - -```go -// First launch: show setup form -// User provides: host, port, database, username, password -// Credentials are encrypted and stored in SQLite -// Connection is tested before saving - -func SetupConnection(host, port, dbName, user, password string) error { - // Test connection to MariaDB - // If successful, encrypt and save to SQLite - // Create/migrate qt_* tables if user has permissions -} - -func CheckWritePermission(db *gorm.DB, tableName string) bool { - // Check if current user can INSERT into table - // Used to enable/disable pricelist creation UI -} -``` - -### 2. Pricelist Creation - -```go -// Create snapshot of current prices from qt_lot_metadata -// Version format: YYYY-MM-DD-NNN (NNN = sequential number for the day) - -func CreatePricelist(name string, createdBy string) (*Pricelist, error) { - version := generateVersion() // e.g., "2024-01-31-001" - expiresAt := time.Now().AddDate(1, 0, 0) // +1 year - - // Copy all prices from qt_lot_metadata - // Insert into qt_pricelists and qt_pricelist_items -} - -func generateVersion() string { - today := time.Now().Format("2006-01-02") - // Count existing pricelists for today - // Return "YYYY-MM-DD-NNN" -} -``` - -### 3. Pricelist Comparison - -```go -type PriceDiff struct { - LotName string - OldPrice float64 - NewPrice float64 - Difference float64 - PercentDiff float64 -} - -// Compare two pricelists and return differences -func ComparePricelists(oldID, newID int) ([]PriceDiff, error) - -// Compare specification's pricelist with latest available -func GetSpecificationPriceDiff(specUUID string) ([]PriceDiff, float64, error) { - // Returns item diffs and total price difference -} -``` - -### 4. Specification Upgrade - -```go -// Upgrade specification to use newer pricelist -func UpgradeSpecificationPricelist(specUUID string, newPricelistID int) error { - // Update pricelist_id - // Recalculate prices from new pricelist - // Increment revision number - // Update old pricelist usage_count-- - // Update new pricelist usage_count++ -} -``` - -### 5. Pricelist Cleanup - -```go -// Cron job: cleanup old unused pricelists -// Run weekly: 0 4 * * 0 -func CleanupOldPricelists() error { - // Delete pricelists where: - // - expires_at < NOW() - // - usage_count = 0 - // - is_active = false OR created_at < NOW() - 1 year -} -``` - -### 6. Sync Service - -```go -// Sync pricelists from server to local SQLite -func SyncPricelists() error { - // Fetch all active pricelists from MariaDB - // Update local_pricelists table - // For pricelists used by local specs, also sync items -} - -// Check if sync is needed -func NeedSync() bool { - // Compare last_sync timestamp with server - // Return true if new pricelists available -} -``` - -### 7. Part Number Parsing - -```go -// "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 -} -``` - -### 8. Price Calculation Methods - -```go -func CalculateMedian(prices []float64) float64 -func CalculateAverage(prices []float64) float64 -func CalculateWeightedMedian(prices []PricePoint, decayDays int) float64 -``` - -### 9. Price Freshness - -```go -func GetPriceFreshness(daysSinceUpdate int, quoteCount int) string { - if daysSinceUpdate < 30 && quoteCount >= 3 { - return "fresh" // green - } else if daysSinceUpdate < 60 { - return "normal" // yellow - } else if daysSinceUpdate < 90 { - return "stale" // orange - } - return "critical" // red -} -``` +**Pricelist version:** `YYYY-MM-DD-NNN` (e.g., `2024-01-31-001`) ## API Endpoints -### Setup (no auth required) -``` -GET /setup → DB connection form (if not configured) -POST /setup → Save connection settings -POST /setup/test → Test connection without saving -GET /setup/status → Check if configured and connected -``` - -### Components -``` -GET /api/components → List with pagination -GET /api/components?category=CPU&search=AMD → Filtered list -GET /api/components/:lot_name → Single component details -GET /api/categories → Category list -``` - -### Pricelists -``` -GET /api/pricelists → List all pricelists -POST /api/pricelists → Create new pricelist (requires write permission) -GET /api/pricelists/:id → Pricelist details -GET /api/pricelists/:id/items → Pricelist items with pagination -DELETE /api/pricelists/:id → Delete pricelist (if usage_count=0) -GET /api/pricelists/latest → Get latest active pricelist -POST /api/pricelists/compare → Compare two pricelists -``` - -### Projects -``` -GET /api/projects → List all projects -POST /api/projects → Create project -GET /api/projects/:uuid → Project with specifications -PUT /api/projects/:uuid → Update project -DELETE /api/projects/:uuid → Delete project and specs -``` - -### Specifications -``` -GET /api/projects/:uuid/specs → List specifications -POST /api/projects/:uuid/specs → Create specification -GET /api/specs/:spec_uuid → Specification details -PUT /api/specs/:spec_uuid → Update specification -DELETE /api/specs/:spec_uuid → Delete specification -POST /api/specs/:spec_uuid/upgrade → Upgrade to new pricelist -GET /api/specs/:spec_uuid/diff → Show price diff with latest pricelist -POST /api/specs/:spec_uuid/new-revision → Create new revision -``` - -### Sync -``` -GET /api/sync/status → Sync status (last sync, pending changes) -POST /api/sync/pricelists → Sync pricelists from server -POST /api/sync/push → Push local changes to server -POST /api/sync/pull → Pull all data from server -``` - -### Export -``` -POST /api/export/xlsx → Export specification as XLSX -POST /api/export/pdf → Export specification as PDF (future) -GET /api/specs/:uuid/export → Export single spec -GET /api/projects/:uuid/export → Export all project specs -``` - -### htmx Partials -``` -GET /partials/components?category=CPU → Component list HTML -GET /partials/spec-items/:spec_uuid → Specification items HTML -GET /partials/price-diff/:spec_uuid → Price diff table HTML -GET /partials/project-specs/:project_uuid → Project specifications list -``` - -## Frontend Guidelines - -- **Mobile-first** design -- Use **htmx** for interactivity (hx-get, hx-post, hx-target, hx-swap) -- Use **Tailwind CSS** via CDN -- Minimal custom JavaScript -- Color scheme for price freshness: - - `text-green-600 bg-green-50` - fresh - - `text-yellow-600 bg-yellow-50` - normal - - `text-orange-600 bg-orange-50` - stale - - `text-red-600 bg-red-50` - critical -- Sync status indicator in header -- Offline mode indicator when server unavailable +| 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 | ## Commands - ```bash -# Run development server -go run ./cmd/server - -# Run importer (one-time setup) -go run ./cmd/importer - -# Run cron jobs manually -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 - -# Build for production +go run ./cmd/server # Dev server +go run ./cmd/cron -job=X # cleanup-pricelists | update-prices | update-popularity CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge ./cmd/server -CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge-cron ./cmd/cron - -# Run tests -go test ./... ``` -## Cron Jobs - -- **Pricelist cleanup**: Weekly on Sunday at 4 AM (0 4 * * 0) -- **Price updates**: Daily at 2 AM (0 2 * * *) -- **Popularity score updates**: Daily at 3 AM (0 3 * * *) - ## Code Style +- gofmt, structured logging (slog), wrap errors with context +- snake_case files, PascalCase types +- RBAC disabled: DB username = user_id via `models.EnsureDBUser()` -- Use standard Go formatting (gofmt) -- Error handling: always check errors, wrap with context -- Logging: use structured logging (slog) -- Comments: in Russian or English, be consistent -- File naming: snake_case for files, PascalCase for types - -## Migration Notes - -### From qt_configurations to qt_specifications - -When migrating existing data: -1. Create a default project for orphan configurations -2. Create initial pricelist from current qt_lot_metadata prices -3. Convert qt_configurations to qt_specifications with default variant="Base", rev=1 -4. Link all specs to initial pricelist - -### RBAC Disabled - -During Phase 1-3, RBAC is disabled: -- No login required -- All users have full access -- Write permissions determined by MariaDB user privileges -- qt_users table exists but not used +## UI Guidelines +- htmx (hx-get/post/target/swap), Tailwind CDN +- Freshness colors: green (fresh) → yellow → orange → red (critical) +- Sync status + offline indicator in header diff --git a/LOCAL_FIRST_INTEGRATION.md b/LOCAL_FIRST_INTEGRATION.md new file mode 100644 index 0000000..1ec4859 --- /dev/null +++ b/LOCAL_FIRST_INTEGRATION.md @@ -0,0 +1,178 @@ +# Local-First Architecture Integration Guide + +## Overview + +QuoteForge теперь поддерживает local-first архитектуру: приложение ВСЕГДА работает с SQLite (localdb), MariaDB используется только для синхронизации. + +## Реализованные компоненты + +### 1. Конвертеры моделей (`internal/localdb/converters.go`) + +Конвертеры между MariaDB и SQLite моделями: +- `ConfigurationToLocal()` / `LocalToConfiguration()` +- `PricelistToLocal()` / `LocalToPricelist()` +- `ComponentToLocal()` / `LocalToComponent()` + +### 2. LocalDB методы (`internal/localdb/localdb.go`) + +Добавлены методы для работы с pending changes: +- `MarkChangesSynced(ids []int64)` - помечает изменения как синхронизированные +- `GetPendingCount()` - возвращает количество несинхронизированных изменений + +### 3. Sync Service расширения (`internal/services/sync/service.go`) + +Новые методы: +- `SyncPricelistsIfNeeded()` - проверяет и скачивает новые прайслисты при необходимости +- `PushPendingChanges()` - отправляет все pending changes на сервер +- `pushSingleChange()` - обрабатывает один pending change +- `pushConfigurationCreate/Update/Delete()` - специфичные методы для конфигураций + +**ВАЖНО**: Конструктор изменен - теперь требует `ConfigurationRepository`: +```go +syncService := sync.NewService(pricelistRepo, configRepo, local) +``` + +### 4. LocalConfigurationService (`internal/services/local_configuration.go`) + +Новый сервис для работы с конфигурациями в local-first режиме: +- Все операции CRUD работают через SQLite +- Автоматически добавляет изменения в pending_changes +- При создании конфигурации (если online) проверяет новые прайслисты + +```go +localConfigService := services.NewLocalConfigurationService( + localDB, + syncService, + quoteService, + isOnlineFunc, +) +``` + +### 5. Sync Handler расширения (`internal/handlers/sync.go`) + +Новые endpoints: +- `POST /api/sync/push` - отправить pending changes на сервер +- `GET /api/sync/pending/count` - получить количество pending changes +- `GET /api/sync/pending` - получить список pending changes + +## Интеграция + +### Шаг 1: Обновить main.go + +```go +// В cmd/server/main.go +syncService := sync.NewService(pricelistRepo, configRepo, local) + +// Создать isOnline функцию +isOnlineFunc := func() bool { + sqlDB, err := db.DB() + if err != nil { + return false + } + return sqlDB.Ping() == nil +} + +// Создать LocalConfigurationService +localConfigService := services.NewLocalConfigurationService( + local, + syncService, + quoteService, + isOnlineFunc, +) +``` + +### Шаг 2: Обновить ConfigurationHandler + +Заменить `ConfigurationService` на `LocalConfigurationService` в handlers: + +```go +// Было: +configHandler := handlers.NewConfigurationHandler(configService, exportService) + +// Стало: +configHandler := handlers.NewConfigurationHandler(localConfigService, exportService) +``` + +### Шаг 3: Добавить endpoints для sync + +В роутере добавить: +```go +syncGroup := router.Group("/api/sync") +{ + syncGroup.POST("/push", syncHandler.PushPendingChanges) + syncGroup.GET("/pending/count", syncHandler.GetPendingCount) + syncGroup.GET("/pending", syncHandler.GetPendingChanges) +} +``` + +## Как это работает + +### Создание конфигурации + +1. Пользователь создает конфигурацию +2. `LocalConfigurationService.Create()`: + - Если online → `SyncPricelistsIfNeeded()` проверяет новые прайслисты + - Сохраняет конфигурацию в SQLite + - Добавляет в `pending_changes` с operation="create" +3. Конфигурация доступна локально сразу + +### Синхронизация с сервером + +**Manual sync:** +```bash +POST /api/sync/push +``` + +**Background sync (TODO):** +- Периодический worker вызывает `syncService.PushPendingChanges()` +- Проверяет online статус +- Отправляет все pending changes на сервер +- Удаляет успешно синхронизированные записи + +### Offline режим + +1. Все операции работают нормально через SQLite +2. Изменения копятся в `pending_changes` +3. При восстановлении соединения автоматически синхронизируются + +## Pending Changes Queue + +Таблица `pending_changes`: +```go +type PendingChange struct { + ID int64 // Auto-increment + EntityType string // "configuration", "project", "specification" + EntityUUID string // UUID сущности + Operation string // "create", "update", "delete" + Payload string // JSON snapshot сущности + CreatedAt time.Time + Attempts int // Счетчик попыток синхронизации + LastError string // Последняя ошибка синхронизации +} +``` + +## TODO для Phase 2.5 + +- [ ] Background sync worker (автоматическая синхронизация каждые N минут) +- [ ] Conflict resolution (при конфликтах обновления) +- [ ] UI: pending counter в header +- [ ] UI: manual sync button +- [ ] UI: conflict alerts +- [ ] Retry logic для failed pending changes +- [ ] RefreshPrices для local mode (через local_components) + +## Testing + +```bash +# Compile +go build ./cmd/server + +# Run +./quoteforge + +# Check pending changes +curl http://localhost:8080/api/sync/pending/count + +# Manual sync +curl -X POST http://localhost:8080/api/sync/push +``` diff --git a/cmd/cron/main.go b/cmd/cron/main.go index 3157fee..499f639 100644 --- a/cmd/cron/main.go +++ b/cmd/cron/main.go @@ -3,7 +3,6 @@ package main import ( "flag" "log" - "time" "git.mchus.pro/mchus/quoteforge/internal/config" "git.mchus.pro/mchus/quoteforge/internal/models" diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go new file mode 100644 index 0000000..9523f6f --- /dev/null +++ b/cmd/migrate/main.go @@ -0,0 +1,162 @@ +package main + +import ( + "flag" + "fmt" + "log" + "time" + + "git.mchus.pro/mchus/quoteforge/internal/config" + "git.mchus.pro/mchus/quoteforge/internal/localdb" + "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") + localDBPath := flag.String("localdb", "./data/settings.db", "path to local SQLite database") + dryRun := flag.Bool("dry-run", false, "show what would be migrated without actually doing it") + flag.Parse() + + log.Println("QuoteForge Configuration Migration Tool") + log.Println("========================================") + + // Load config for MariaDB connection + cfg, err := config.Load(*configPath) + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + // Connect to MariaDB + log.Printf("Connecting to MariaDB at %s:%d...", cfg.Database.Host, cfg.Database.Port) + mariaDB, 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 MariaDB: %v", err) + } + log.Println("Connected to MariaDB") + + // Initialize local SQLite + log.Printf("Opening local SQLite at %s...", *localDBPath) + local, err := localdb.New(*localDBPath) + if err != nil { + log.Fatalf("Failed to initialize local database: %v", err) + } + log.Println("Local SQLite initialized") + + // Count configurations in MariaDB + var serverCount int64 + if err := mariaDB.Model(&models.Configuration{}).Count(&serverCount).Error; err != nil { + log.Fatalf("Failed to count configurations: %v", err) + } + log.Printf("Found %d configurations in MariaDB", serverCount) + + if serverCount == 0 { + log.Println("No configurations to migrate") + return + } + + // Get all configurations from MariaDB + var configs []models.Configuration + if err := mariaDB.Preload("User").Find(&configs).Error; err != nil { + log.Fatalf("Failed to fetch configurations: %v", err) + } + + // Check existing local configurations + localCount := local.CountConfigurations() + log.Printf("Found %d configurations in local SQLite", localCount) + + if *dryRun { + log.Println("\n[DRY RUN] Would migrate the following configurations:") + for _, c := range configs { + userName := "unknown" + if c.User != nil { + userName = c.User.Username + } + log.Printf(" - %s (UUID: %s, User: %s, Items: %d)", c.Name, c.UUID, userName, len(c.Items)) + } + log.Printf("\nTotal: %d configurations", len(configs)) + return + } + + // Migrate configurations + log.Println("\nMigrating configurations...") + migrated := 0 + skipped := 0 + errors := 0 + + for _, c := range configs { + // Check if already exists + existing, err := local.GetConfigurationByUUID(c.UUID) + if err == nil && existing.ID > 0 { + log.Printf(" SKIP: %s (already exists)", c.Name) + skipped++ + continue + } + + // Convert items + localItems := make(localdb.LocalConfigItems, len(c.Items)) + for i, item := range c.Items { + localItems[i] = localdb.LocalConfigItem{ + LotName: item.LotName, + Quantity: item.Quantity, + UnitPrice: item.UnitPrice, + } + } + + // Create local configuration + now := time.Now() + localConfig := &localdb.LocalConfiguration{ + UUID: c.UUID, + ServerID: &c.ID, + Name: c.Name, + Items: localItems, + TotalPrice: c.TotalPrice, + CustomPrice: c.CustomPrice, + Notes: c.Notes, + IsTemplate: c.IsTemplate, + ServerCount: c.ServerCount, + CreatedAt: c.CreatedAt, + UpdatedAt: now, + SyncedAt: &now, + SyncStatus: "synced", + OriginalUserID: c.UserID, + } + + if err := local.SaveConfiguration(localConfig); err != nil { + log.Printf(" ERROR: %s - %v", c.Name, err) + errors++ + continue + } + + log.Printf(" OK: %s (%d items)", c.Name, len(c.Items)) + migrated++ + } + + log.Println("\n========================================") + log.Printf("Migration complete!") + log.Printf(" Migrated: %d", migrated) + log.Printf(" Skipped: %d", skipped) + log.Printf(" Errors: %d", errors) + + // Save connection settings to local SQLite if not exists + if !local.HasSettings() { + log.Println("\nSaving connection settings to local SQLite...") + if err := local.SaveSettings( + cfg.Database.Host, + cfg.Database.Port, + cfg.Database.Name, + cfg.Database.User, + cfg.Database.Password, + ); err != nil { + log.Printf("Warning: Failed to save settings: %v", err) + } else { + log.Println("Connection settings saved") + } + } + + fmt.Println("\nDone! You can now run the server with: go run ./cmd/server") +} diff --git a/cmd/server/main.go b/cmd/server/main.go index b292b25..d95108f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -3,79 +3,127 @@ package main import ( "context" "flag" + "fmt" "log/slog" "net/http" "os" "os/signal" + "strconv" "syscall" "time" "github.com/gin-gonic/gin" "git.mchus.pro/mchus/quoteforge/internal/config" + "git.mchus.pro/mchus/quoteforge/internal/db" "git.mchus.pro/mchus/quoteforge/internal/handlers" + "git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/middleware" "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" - "golang.org/x/crypto/bcrypt" + "git.mchus.pro/mchus/quoteforge/internal/services/sync" "gorm.io/driver/mysql" "gorm.io/gorm" "gorm.io/gorm/logger" ) +const ( + localDBPath = "./data/settings.db" +) + func main() { - configPath := flag.String("config", "config.yaml", "path to config file") + configPath := flag.String("config", "config.yaml", "path to config file (optional, for server settings)") migrate := flag.Bool("migrate", false, "run database migrations") flag.Parse() - cfg, err := config.Load(*configPath) + // Initialize local SQLite database (always used) + local, err := localdb.New(localDBPath) if err != nil { - slog.Error("failed to load config", "error", err) + slog.Error("failed to initialize local database", "error", err) os.Exit(1) } + // Check if running in setup mode (no connection settings) + if !local.HasSettings() { + slog.Info("no database settings found, starting setup mode") + runSetupMode(local) + return + } + + // Load config for server settings (optional) + cfg, err := config.Load(*configPath) + if err != nil { + // Use defaults if config file doesn't exist + slog.Info("config file not found, using defaults", "path", *configPath) + cfg = &config.Config{} + } + setConfigDefaults(cfg) + setupLogger(cfg.Logging) + // Create connection manager and try to connect immediately if settings exist + connMgr := db.NewConnectionManager(local) + + dbUser := local.GetDBUser() + dbUserID := uint(1) + + // Try to connect to MariaDB on startup + mariaDB, err := connMgr.GetDB() + if err != nil { + slog.Warn("failed to connect to MariaDB on startup, starting in offline mode", "error", err) + mariaDB = nil + } else { + slog.Info("successfully connected to MariaDB on startup") + // Ensure DB user exists and get their ID + if dbUserID, err = models.EnsureDBUser(mariaDB, dbUser); err != nil { + slog.Error("failed to ensure DB user", "error", err) + // Continue with default ID + dbUserID = uint(1) + } + } + slog.Info("starting QuoteForge server", "host", cfg.Server.Host, "port", cfg.Server.Port, - "mode", cfg.Server.Mode, + "db_user", dbUser, + "db_user_id", dbUserID, + "online", mariaDB != nil, ) - db, err := setupDatabase(cfg.Database) - if err != nil { - slog.Error("failed to connect to database", "error", err) - os.Exit(1) - } - if *migrate { + if mariaDB == nil { + slog.Error("cannot run migrations: database not available") + os.Exit(1) + } slog.Info("running database migrations...") - if err := models.Migrate(db); err != nil { + if err := models.Migrate(mariaDB); err != nil { slog.Error("migration failed", "error", err) os.Exit(1) } - if err := models.SeedCategories(db); err != nil { + if err := models.SeedCategories(mariaDB); err != nil { slog.Error("seeding categories failed", "error", err) os.Exit(1) } - // Create default admin user (admin / admin123) - adminHash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost) - if err := models.SeedAdminUser(db, string(adminHash)); err != nil { - slog.Error("seeding admin user failed", "error", err) - os.Exit(1) - } slog.Info("migrations completed") } gin.SetMode(cfg.Server.Mode) - router, err := setupRouter(db, cfg) + router, syncService, err := setupRouter(cfg, local, connMgr, mariaDB, dbUserID) if err != nil { slog.Error("failed to setup router", "error", err) os.Exit(1) } + // Start background sync worker (will auto-skip when offline) + workerCtx, workerCancel := context.WithCancel(context.Background()) + defer workerCancel() + + syncWorker := sync.NewWorker(syncService, connMgr, 5*time.Minute) + go syncWorker.Start(workerCtx) + srv := &http.Server{ Addr: cfg.Address(), Handler: router, @@ -97,6 +145,11 @@ func main() { slog.Info("shutting down server...") + // Stop background sync worker first + syncWorker.Stop() + workerCancel() + + // Then shutdown HTTP server ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -107,6 +160,111 @@ func main() { slog.Info("server stopped") } +func setConfigDefaults(cfg *config.Config) { + if cfg.Server.Host == "" { + cfg.Server.Host = "0.0.0.0" + } + if cfg.Server.Port == 0 { + cfg.Server.Port = 8080 + } + if cfg.Server.Mode == "" { + cfg.Server.Mode = "release" + } + if cfg.Server.ReadTimeout == 0 { + cfg.Server.ReadTimeout = 30 * time.Second + } + if cfg.Server.WriteTimeout == 0 { + cfg.Server.WriteTimeout = 30 * time.Second + } + if cfg.Pricing.DefaultMethod == "" { + cfg.Pricing.DefaultMethod = "weighted_median" + } + if cfg.Pricing.DefaultPeriodDays == 0 { + cfg.Pricing.DefaultPeriodDays = 90 + } + if cfg.Pricing.FreshnessGreenDays == 0 { + cfg.Pricing.FreshnessGreenDays = 30 + } + if cfg.Pricing.FreshnessYellowDays == 0 { + cfg.Pricing.FreshnessYellowDays = 60 + } + if cfg.Pricing.FreshnessRedDays == 0 { + cfg.Pricing.FreshnessRedDays = 90 + } + if cfg.Pricing.MinQuotesForMedian == 0 { + cfg.Pricing.MinQuotesForMedian = 3 + } +} + +// runSetupMode starts a minimal server that only serves the setup page +func runSetupMode(local *localdb.LocalDB) { + restartSig := make(chan struct{}, 1) + + // In setup mode, we don't have a connection manager yet (will restart after setup) + setupHandler, err := handlers.NewSetupHandler(local, nil, "web/templates", restartSig) + if err != nil { + slog.Error("failed to create setup handler", "error", err) + os.Exit(1) + } + + gin.SetMode(gin.ReleaseMode) + router := gin.New() + router.Use(gin.Recovery()) + + router.Static("/static", "web/static") + + // Setup routes only + router.GET("/", func(c *gin.Context) { + c.Redirect(http.StatusFound, "/setup") + }) + router.GET("/setup", setupHandler.ShowSetup) + router.POST("/setup", setupHandler.SaveConnection) + router.POST("/setup/test", setupHandler.TestConnection) + router.GET("/setup/status", setupHandler.GetStatus) + + // Health check + router.GET("/health", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "setup_required", + "time": time.Now().UTC().Format(time.RFC3339), + }) + }) + + addr := ":8080" + slog.Info("starting setup mode server", "address", addr) + slog.Info("open http://localhost:8080/setup to configure database connection") + + srv := &http.Server{ + Addr: addr, + Handler: router, + } + + go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("server error", "error", err) + os.Exit(1) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + + select { + case <-quit: + slog.Info("setup mode server stopped") + case <-restartSig: + slog.Info("restarting application with saved settings...") + + // Graceful shutdown + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + srv.Shutdown(ctx) + + // Restart process with same arguments + restartProcess() + } +} + func setupLogger(cfg config.LoggingConfig) { var level slog.Level switch cfg.Level { @@ -132,10 +290,10 @@ func setupLogger(cfg config.LoggingConfig) { slog.SetDefault(slog.New(handler)) } -func setupDatabase(cfg config.DatabaseConfig) (*gorm.DB, error) { +func setupDatabaseFromDSN(dsn string) (*gorm.DB, error) { gormLogger := logger.Default.LogMode(logger.Silent) - db, err := gorm.Open(mysql.Open(cfg.DSN()), &gorm.Config{ + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ Logger: gormLogger, }) if err != nil { @@ -147,44 +305,95 @@ func setupDatabase(cfg config.DatabaseConfig) (*gorm.DB, error) { return nil, err } - sqlDB.SetMaxOpenConns(cfg.MaxOpenConns) - sqlDB.SetMaxIdleConns(cfg.MaxIdleConns) - sqlDB.SetConnMaxLifetime(cfg.ConnMaxLifetime) + sqlDB.SetMaxOpenConns(25) + sqlDB.SetMaxIdleConns(5) + sqlDB.SetConnMaxLifetime(5 * time.Minute) return db, nil } -func setupRouter(db *gorm.DB, cfg *config.Config) (*gin.Engine, error) { +func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.ConnectionManager, mariaDB *gorm.DB, dbUserID uint) (*gin.Engine, *sync.Service, error) { + // mariaDB may be nil if we're in offline mode + // Repositories - userRepo := repository.NewUserRepository(db) - componentRepo := repository.NewComponentRepository(db) - categoryRepo := repository.NewCategoryRepository(db) - priceRepo := repository.NewPriceRepository(db) - configRepo := repository.NewConfigurationRepository(db) - alertRepo := repository.NewAlertRepository(db) - statsRepo := repository.NewStatsRepository(db) + var componentRepo *repository.ComponentRepository + var categoryRepo *repository.CategoryRepository + var priceRepo *repository.PriceRepository + var alertRepo *repository.AlertRepository + var statsRepo *repository.StatsRepository + var pricelistRepo *repository.PricelistRepository + + // Only initialize repositories if we have a database connection + 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 { + // In offline mode, we'll use nil repositories or handle them differently + // This is handled in the sync service and other components + } // Services - authService := services.NewAuthService(userRepo, cfg.Auth) - pricingService := pricing.NewService(componentRepo, priceRepo, cfg.Pricing) - componentService := services.NewComponentService(componentRepo, categoryRepo, statsRepo) - quoteService := services.NewQuoteService(componentRepo, statsRepo, pricingService) - configService := services.NewConfigurationService(configRepo, componentRepo, quoteService) - exportService := services.NewExportService(cfg.Export, categoryRepo) - alertService := alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing) + 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 syncService *sync.Service + + // Sync service always uses ConnectionManager (works offline and online) + 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, pricingService) + exportService = services.NewExportService(cfg.Export, categoryRepo) + alertService = alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing) + pricelistService = pricelist.NewService(mariaDB, pricelistRepo, componentRepo) + } else { + // In offline mode, we still need to create services that don't require DB + pricingService = pricing.NewService(nil, nil, cfg.Pricing) + componentService = services.NewComponentService(nil, nil, nil) + quoteService = services.NewQuoteService(nil, nil, pricingService) + exportService = services.NewExportService(cfg.Export, nil) + alertService = alerts.NewService(nil, nil, nil, nil, cfg.Alerts, cfg.Pricing) + pricelistService = pricelist.NewService(nil, nil, nil) + } + + // isOnline function for local-first architecture + isOnline := func() bool { + return connMgr.IsOnline() + } + + // Local-first configuration service (replaces old ConfigurationService) + configService := services.NewLocalConfigurationService(local, syncService, quoteService, isOnline) // Handlers - authHandler := handlers.NewAuthHandler(authService, userRepo) - componentHandler := handlers.NewComponentHandler(componentService) + componentHandler := handlers.NewComponentHandler(componentService, local) quoteHandler := handlers.NewQuoteHandler(quoteService) - configHandler := handlers.NewConfigurationHandler(configService, exportService) exportHandler := handlers.NewExportHandler(exportService, configService, componentService) - pricingHandler := handlers.NewPricingHandler(db, pricingService, alertService, componentRepo, priceRepo, statsRepo) + pricingHandler := handlers.NewPricingHandler(mariaDB, pricingService, alertService, componentRepo, priceRepo, statsRepo) + pricelistHandler := handlers.NewPricelistHandler(pricelistService, local) + syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, "web/templates") + if err != nil { + return nil, nil, fmt.Errorf("creating sync handler: %w", err) + } + + // Setup handler (for reconfiguration) - no restart signal in normal mode + setupHandler, err := handlers.NewSetupHandler(local, connMgr, "web/templates", nil) + if err != nil { + return nil, nil, fmt.Errorf("creating setup handler: %w", err) + } // Web handler (templates) webHandler, err := handlers.NewWebHandler("web/templates", componentService) if err != nil { - return nil, err + return nil, nil, err } // Router @@ -192,6 +401,7 @@ func setupRouter(db *gorm.DB, cfg *config.Config) (*gin.Engine, error) { router.Use(gin.Recovery()) router.Use(requestLogger()) router.Use(middleware.CORS()) + router.Use(middleware.OfflineDetector(connMgr, local)) // Static files router.Static("/static", "web/static") @@ -207,42 +417,68 @@ func setupRouter(db *gorm.DB, cfg *config.Config) (*gin.Engine, error) { // DB status endpoint router.GET("/api/db-status", func(c *gin.Context) { var lotCount, lotLogCount, metadataCount int64 - var dbOK bool = true + var dbOK bool = false var dbError string - sqlDB, err := db.DB() - if err != nil { - dbOK = false - dbError = err.Error() - } else if err := sqlDB.Ping(); err != nil { - dbOK = false - dbError = err.Error() + // Check if connection exists (fast check, no reconnect attempt) + 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 + dbError = "Database not connected (offline mode)" + if status.LastError != "" { + dbError = status.LastError + } } - db.Table("lot").Count(&lotCount) - db.Table("lot_log").Count(&lotLogCount) - db.Table("qt_lot_metadata").Count(&metadataCount) - c.JSON(http.StatusOK, gin.H{ "connected": dbOK, "error": dbError, "lot_count": lotCount, "lot_log_count": lotLogCount, "metadata_count": metadataCount, + "db_user": local.GetDBUser(), }) }) + // Current user info (DB user, not app user) + router.GET("/api/current-user", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "username": local.GetDBUser(), + "role": "db_user", + }) + }) + + // Setup routes (for reconfiguration) + router.GET("/setup", setupHandler.ShowSetup) + router.POST("/setup", setupHandler.SaveConnection) + router.POST("/setup/test", setupHandler.TestConnection) + router.GET("/setup/status", setupHandler.GetStatus) + // Web pages router.GET("/", webHandler.Index) - router.GET("/login", webHandler.Login) router.GET("/configs", webHandler.Configs) router.GET("/configurator", webHandler.Configurator) + 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/:id", webHandler.PricelistDetail) router.GET("/admin/pricing", webHandler.AdminPricing) // htmx partials partials := router.Group("/partials") { partials.GET("/components", webHandler.ComponentsPartial) + partials.GET("/sync-status", syncHandler.SyncStatusPartial) } // API routes @@ -252,16 +488,7 @@ func setupRouter(db *gorm.DB, cfg *config.Config) (*gin.Engine, error) { c.JSON(http.StatusOK, gin.H{"message": "pong"}) }) - // Auth (public) - auth := api.Group("/auth") - { - auth.POST("/login", authHandler.Login) - auth.POST("/refresh", authHandler.Refresh) - auth.POST("/logout", authHandler.Logout) - auth.GET("/me", middleware.Auth(authService), authHandler.Me) - } - - // Components (public read, for quote builder) + // Components (public read) components := api.Group("/components") { components.GET("", componentHandler.List) @@ -271,45 +498,155 @@ func setupRouter(db *gorm.DB, cfg *config.Config) (*gin.Engine, error) { // Categories (public) api.GET("/categories", componentHandler.GetCategories) - // Quote (public, for anonymous quote building) + // Quote (public) quote := api.Group("/quote") { quote.POST("/validate", quoteHandler.Validate) quote.POST("/calculate", quoteHandler.Calculate) } - // Export (public, for anonymous exports) + // Export (public) export := api.Group("/export") { export.POST("/csv", exportHandler.ExportCSV) } - // Configurations (requires auth) - configs := api.Group("/configs") - configs.Use(middleware.Auth(authService)) - configs.Use(middleware.RequireEditor()) + // Pricelists (public - RBAC disabled in Phase 1-3) + pricelists := api.Group("/pricelists") { - configs.GET("", configHandler.List) - configs.POST("", configHandler.Create) - configs.GET("/:uuid", configHandler.Get) - configs.PUT("/:uuid", configHandler.Update) - configs.PATCH("/:uuid/rename", configHandler.Rename) - configs.POST("/:uuid/clone", configHandler.Clone) - configs.POST("/:uuid/refresh-prices", configHandler.RefreshPrices) - configs.DELETE("/:uuid", configHandler.Delete) - // configs.GET("/:uuid/export", configHandler.ExportJSON) - configs.GET("/:uuid/csv", exportHandler.ExportConfigCSV) - // configs.POST("/import", configHandler.ImportJSON) + 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.POST("", pricelistHandler.Create) + pricelists.DELETE("/:id", pricelistHandler.Delete) } - } - // Admin routes - admin := router.Group("/admin") - admin.Use(middleware.Auth(authService)) - { - // Pricing admin - pricingAdmin := admin.Group("/pricing") - pricingAdmin.Use(middleware.RequirePricingAdmin()) + // Configurations (public - RBAC disabled) + configs := api.Group("/configs") + { + configs.GET("", func(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20")) + + cfgs, total, err := configService.ListAll(page, perPage) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "configurations": cfgs, + "total": total, + "page": page, + "per_page": perPage, + }) + }) + + configs.POST("", func(c *gin.Context) { + var req services.CreateConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + config, err := configService.Create(dbUserID, &req) // use DB user ID + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, config) + }) + + configs.GET("/:uuid", func(c *gin.Context) { + uuid := c.Param("uuid") + config, err := configService.GetByUUIDNoAuth(uuid) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"}) + return + } + c.JSON(http.StatusOK, config) + }) + + configs.PUT("/:uuid", func(c *gin.Context) { + uuid := c.Param("uuid") + var req services.CreateConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + config, err := configService.UpdateNoAuth(uuid, &req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, config) + }) + + configs.DELETE("/:uuid", func(c *gin.Context) { + uuid := c.Param("uuid") + if err := configService.DeleteNoAuth(uuid); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "deleted"}) + }) + + configs.PATCH("/:uuid/rename", func(c *gin.Context) { + uuid := c.Param("uuid") + var req struct { + Name string `json:"name"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + config, err := configService.RenameNoAuth(uuid, req.Name) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, config) + }) + + configs.POST("/:uuid/clone", func(c *gin.Context) { + uuid := c.Param("uuid") + var req struct { + Name string `json:"name"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + config, err := configService.CloneNoAuth(uuid, req.Name, dbUserID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, config) + }) + + configs.POST("/:uuid/refresh-prices", func(c *gin.Context) { + uuid := c.Param("uuid") + config, err := configService.RefreshPricesNoAuth(uuid) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, config) + }) + } + + // Pricing admin (public - RBAC disabled) + pricingAdmin := api.Group("/admin/pricing") { pricingAdmin.GET("/stats", pricingHandler.GetStats) pricingAdmin.GET("/components", pricingHandler.ListComponents) @@ -323,9 +660,42 @@ func setupRouter(db *gorm.DB, cfg *config.Config) (*gin.Engine, error) { 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("/info", syncHandler.GetInfo) + syncAPI.POST("/components", syncHandler.SyncComponents) + syncAPI.POST("/pricelists", syncHandler.SyncPricelists) + syncAPI.POST("/all", syncHandler.SyncAll) + syncAPI.POST("/push", syncHandler.PushPendingChanges) + syncAPI.GET("/pending/count", syncHandler.GetPendingCount) + syncAPI.GET("/pending", syncHandler.GetPendingChanges) + } } - return router, nil + return router, syncService, nil +} + +// restartProcess restarts the current process with the same arguments +func restartProcess() { + executable, err := os.Executable() + if err != nil { + slog.Error("failed to get executable path", "error", err) + os.Exit(1) + } + + args := os.Args + env := os.Environ() + + slog.Info("executing restart", "executable", executable, "args", args) + + err = syscall.Exec(executable, args, env) + if err != nil { + slog.Error("failed to restart process", "error", err) + os.Exit(1) + } } func requestLogger() gin.HandlerFunc { diff --git a/go.mod b/go.mod index 8c9ac3f..151af24 100644 --- a/go.mod +++ b/go.mod @@ -4,19 +4,22 @@ go 1.24.0 require ( github.com/gin-gonic/gin v1.9.1 + github.com/glebarez/sqlite v1.11.0 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/uuid v1.6.0 golang.org/x/crypto v0.43.0 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/mysql v1.5.2 - gorm.io/gorm v1.25.5 + gorm.io/gorm v1.25.7 ) require ( github.com/bytedance/sonic v1.9.1 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect github.com/gin-contrib/sse v0.1.0 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.14.0 // indirect @@ -31,6 +34,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.0.8 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/stretchr/testify v1.11.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect @@ -39,4 +43,8 @@ require ( golang.org/x/sys v0.37.0 // indirect golang.org/x/text v0.30.0 // indirect google.golang.org/protobuf v1.30.0 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect ) diff --git a/go.sum b/go.sum index 3ae81dd..e7f9ba5 100644 --- a/go.sum +++ b/go.sum @@ -7,12 +7,18 @@ github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583j github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -32,6 +38,8 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= @@ -56,6 +64,9 @@ github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZ github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -85,8 +96,9 @@ golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= @@ -98,6 +110,14 @@ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/mysql v1.5.2 h1:QC2HRskSE75wBuOxe0+iCkyJZ+RqpudsQtqkp+IMuXs= gorm.io/driver/mysql v1.5.2/go.mod h1:pQLhh1Ut/WUAySdTHwBpBv6+JKcj+ua4ZFx1QQTBzb8= gorm.io/gorm v1.25.2-0.20230530020048-26663ab9bf55/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= -gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls= -gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.25.7 h1:VsD6acwRjz2zFxGO50gPO6AkNs7KKnvfzUjHQhZDz/A= +gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/db/connection.go b/internal/db/connection.go new file mode 100644 index 0000000..b73ed4c --- /dev/null +++ b/internal/db/connection.go @@ -0,0 +1,328 @@ +package db + +import ( + "context" + "fmt" + "sync" + "time" + + "git.mchus.pro/mchus/quoteforge/internal/localdb" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +const ( + defaultConnectTimeout = 5 * time.Second + defaultPingInterval = 30 * time.Second + defaultReconnectCooldown = 10 * time.Second + + maxOpenConns = 10 + maxIdleConns = 2 + connMaxLifetime = 5 * time.Minute +) + +// ConnectionStatus represents the current status of the database connection +type ConnectionStatus struct { + IsConnected bool + LastCheck time.Time + LastError string // empty if no error + DSNHost string // host:port for display (without password!) +} + +// ConnectionManager manages database connections with thread-safety and connection pooling +type ConnectionManager struct { + localDB *localdb.LocalDB // for getting DSN from settings + mu sync.RWMutex // protects db and state + db *gorm.DB // current connection (nil if not connected) + lastError error // last connection error + lastCheck time.Time // time of last check/attempt + connectTimeout time.Duration // timeout for connection (default: 5s) + pingInterval time.Duration // minimum interval between pings (default: 30s) + reconnectCooldown time.Duration // pause after failed attempt (default: 10s) +} + +// NewConnectionManager creates a new ConnectionManager instance +func NewConnectionManager(localDB *localdb.LocalDB) *ConnectionManager { + return &ConnectionManager{ + localDB: localDB, + connectTimeout: defaultConnectTimeout, + pingInterval: defaultPingInterval, + reconnectCooldown: defaultReconnectCooldown, + db: nil, + lastError: nil, + lastCheck: time.Time{}, + } +} + +// GetDB returns the current database connection, establishing it if needed +// Thread-safe and respects connection cooldowns +func (cm *ConnectionManager) GetDB() (*gorm.DB, error) { + // Handle case where localDB is nil + if cm.localDB == nil { + return nil, fmt.Errorf("local database not initialized") + } + + // First check if we already have a valid connection + cm.mu.RLock() + if cm.db != nil { + // Check if connection is still valid and within ping interval + if time.Since(cm.lastCheck) < cm.pingInterval { + cm.mu.RUnlock() + return cm.db, nil + } + } + cm.mu.RUnlock() + + // Upgrade to write lock + cm.mu.Lock() + defer cm.mu.Unlock() + + // Double-check: someone else might have connected while we were waiting for the write lock + if cm.db != nil { + // Check if connection is still valid and within ping interval + if time.Since(cm.lastCheck) < cm.pingInterval { + return cm.db, nil + } + } + + // Check if we're in cooldown period after a failed attempt + if cm.lastError != nil && time.Since(cm.lastCheck) < cm.reconnectCooldown { + return nil, cm.lastError + } + + // Attempt to connect + err := cm.connect() + if err != nil { + cm.lastError = err + cm.lastCheck = time.Now() + return nil, err + } + + // Update last check time and return success + cm.lastCheck = time.Now() + cm.lastError = nil + return cm.db, nil +} + +// connect establishes a new database connection +func (cm *ConnectionManager) connect() error { + // Get DSN from local settings + dsn, err := cm.localDB.GetDSN() + if err != nil { + return fmt.Errorf("getting DSN: %w", err) + } + + // Create context with timeout + ctx, cancel := context.WithTimeout(context.Background(), cm.connectTimeout) + defer cancel() + + // Open database connection + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + return fmt.Errorf("opening database connection: %w", err) + } + + // Test the connection + sqlDB, err := db.DB() + if err != nil { + return fmt.Errorf("getting sql.DB: %w", err) + } + + // Ping with timeout + if err = sqlDB.PingContext(ctx); err != nil { + return fmt.Errorf("pinging database: %w", err) + } + + // Set connection pool settings + sqlDB.SetMaxOpenConns(maxOpenConns) + sqlDB.SetMaxIdleConns(maxIdleConns) + sqlDB.SetConnMaxLifetime(connMaxLifetime) + + // Store the connection + cm.db = db + + return nil +} + +// IsOnline checks if the database is currently connected and responsive +// Does not attempt to reconnect, only checks current state with caching +func (cm *ConnectionManager) IsOnline() bool { + cm.mu.RLock() + if cm.db == nil { + cm.mu.RUnlock() + return false + } + + // If we've checked recently, return cached result + if time.Since(cm.lastCheck) < cm.pingInterval { + cm.mu.RUnlock() + return true + } + cm.mu.RUnlock() + + // Need to perform actual ping + cm.mu.Lock() + defer cm.mu.Unlock() + + // Double-check after acquiring write lock + if cm.db == nil { + return false + } + + // Perform ping with timeout + ctx, cancel := context.WithTimeout(context.Background(), cm.connectTimeout) + defer cancel() + + sqlDB, err := cm.db.DB() + if err != nil { + cm.lastError = err + cm.lastCheck = time.Now() + cm.db = nil + return false + } + + if err = sqlDB.PingContext(ctx); err != nil { + cm.lastError = err + cm.lastCheck = time.Now() + cm.db = nil + return false + } + + // Update last check time and return success + cm.lastCheck = time.Now() + cm.lastError = nil + return true +} + +// TryConnect forces a new connection attempt (for UI "Reconnect" button) +// Ignores cooldown period +func (cm *ConnectionManager) TryConnect() error { + cm.mu.Lock() + defer cm.mu.Unlock() + + // Attempt to connect + err := cm.connect() + if err != nil { + cm.lastError = err + cm.lastCheck = time.Now() + return err + } + + // Update last check time and clear error + cm.lastCheck = time.Now() + cm.lastError = nil + return nil +} + +// Disconnect closes the current database connection +func (cm *ConnectionManager) Disconnect() { + cm.mu.Lock() + defer cm.mu.Unlock() + + if cm.db != nil { + sqlDB, err := cm.db.DB() + if err == nil { + sqlDB.Close() + } + } + cm.db = nil + cm.lastError = nil +} + +// GetLastError returns the last connection error (thread-safe) +func (cm *ConnectionManager) GetLastError() error { + cm.mu.RLock() + defer cm.mu.RUnlock() + return cm.lastError +} + +// GetStatus returns the current connection status +func (cm *ConnectionManager) GetStatus() ConnectionStatus { + cm.mu.RLock() + defer cm.mu.RUnlock() + + status := ConnectionStatus{ + IsConnected: cm.db != nil, + LastCheck: cm.lastCheck, + LastError: "", + DSNHost: "", + } + + if cm.lastError != nil { + status.LastError = cm.lastError.Error() + } + + // Extract host from DSN for display + if cm.localDB != nil { + if dsn, err := cm.localDB.GetDSN(); err == nil { + // Parse DSN to extract host:port + // Format: user:password@tcp(host:port)/database?... + status.DSNHost = extractHostFromDSN(dsn) + } + } + + return status +} + +// extractHostFromDSN extracts the host:port part from a DSN string +func extractHostFromDSN(dsn string) string { + // Find the tcp( part + tcpStart := 0 + if tcpStart = len("tcp("); tcpStart < len(dsn) && dsn[tcpStart] == '(' { + // Look for the closing parenthesis + parenEnd := -1 + for i := tcpStart + 1; i < len(dsn); i++ { + if dsn[i] == ')' { + parenEnd = i + break + } + } + if parenEnd != -1 { + // Extract host:port part between tcp( and ) + hostPort := dsn[tcpStart+1:parenEnd] + return hostPort + } + } + + // Fallback: try to find host:port by looking for @tcp( pattern + atIndex := -1 + for i := 0; i < len(dsn)-4; i++ { + if dsn[i:i+4] == "@tcp" { + atIndex = i + break + } + } + + if atIndex != -1 { + // Look for the opening parenthesis after @tcp + parenStart := -1 + for i := atIndex + 4; i < len(dsn); i++ { + if dsn[i] == '(' { + parenStart = i + break + } + } + + if parenStart != -1 { + // Look for the closing parenthesis + parenEnd := -1 + for i := parenStart + 1; i < len(dsn); i++ { + if dsn[i] == ')' { + parenEnd = i + break + } + } + + if parenEnd != -1 { + hostPort := dsn[parenStart+1:parenEnd] + return hostPort + } + } + } + + // If we can't parse it, return empty string + return "" +} \ No newline at end of file diff --git a/internal/handlers/component.go b/internal/handlers/component.go index 4791ae0..1609c27 100644 --- a/internal/handlers/component.go +++ b/internal/handlers/component.go @@ -5,16 +5,21 @@ import ( "strconv" "github.com/gin-gonic/gin" + "git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/repository" "git.mchus.pro/mchus/quoteforge/internal/services" ) type ComponentHandler struct { componentService *services.ComponentService + localDB *localdb.LocalDB } -func NewComponentHandler(componentService *services.ComponentService) *ComponentHandler { - return &ComponentHandler{componentService: componentService} +func NewComponentHandler(componentService *services.ComponentService, localDB *localdb.LocalDB) *ComponentHandler { + return &ComponentHandler{ + componentService: componentService, + localDB: localDB, + } } func (h *ComponentHandler) List(c *gin.Context) { @@ -34,6 +39,40 @@ func (h *ComponentHandler) List(c *gin.Context) { return } + // If offline mode (empty result), fallback to local components + if 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 + } + } + c.JSON(http.StatusOK, result) } diff --git a/internal/handlers/export.go b/internal/handlers/export.go index faf618d..6d76c3b 100644 --- a/internal/handlers/export.go +++ b/internal/handlers/export.go @@ -12,13 +12,13 @@ import ( type ExportHandler struct { exportService *services.ExportService - configService *services.ConfigurationService + configService services.ConfigurationGetter componentService *services.ComponentService } func NewExportHandler( exportService *services.ExportService, - configService *services.ConfigurationService, + configService services.ConfigurationGetter, componentService *services.ComponentService, ) *ExportHandler { return &ExportHandler{ diff --git a/internal/handlers/pricelist.go b/internal/handlers/pricelist.go new file mode 100644 index 0000000..f6dc2f5 --- /dev/null +++ b/internal/handlers/pricelist.go @@ -0,0 +1,188 @@ +package handlers + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "git.mchus.pro/mchus/quoteforge/internal/localdb" + "git.mchus.pro/mchus/quoteforge/internal/services/pricelist" +) + +type PricelistHandler struct { + service *pricelist.Service + localDB *localdb.LocalDB +} + +func NewPricelistHandler(service *pricelist.Service, localDB *localdb.LocalDB) *PricelistHandler { + return &PricelistHandler{service: service, localDB: localDB} +} + +// 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")) + + pricelists, total, err := h.service.List(page, perPage) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // If offline (empty list), fallback to local pricelists + if total == 0 && h.localDB != nil { + localPLs, err := h.localDB.GetLocalPricelists() + if err == nil && len(localPLs) > 0 { + // Convert to PricelistSummary format + summaries := make([]map[string]interface{}, len(localPLs)) + for i, lpl := range localPLs { + summaries[i] = map[string]interface{}{ + "id": lpl.ServerID, + "version": lpl.Version, + "created_by": "sync", + "item_count": 0, // Not tracked + "usage_count": 0, // Not tracked in local + "is_active": true, + "created_at": lpl.CreatedAt, + "synced_from": "local", + } + } + + c.JSON(http.StatusOK, gin.H{ + "pricelists": summaries, + "total": len(summaries), + "page": page, + "per_page": perPage, + "offline": true, + }) + return + } + } + + c.JSON(http.StatusOK, gin.H{ + "pricelists": pricelists, + "total": total, + "page": page, + "per_page": perPage, + }) +} + +// 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) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"}) + return + } + + pl, err := h.service.GetByID(uint(id)) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "pricelist not found"}) + return + } + + c.JSON(http.StatusOK, pl) +} + +// Create creates a new pricelist from current prices +func (h *PricelistHandler) Create(c *gin.Context) { + // Get the database username as the creator + createdBy := h.localDB.GetDBUser() + if createdBy == "" { + createdBy = "unknown" + } + + pl, err := h.service.CreateFromCurrentPrices(createdBy) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, pl) +} + +// Delete deletes a pricelist by ID +func (h *PricelistHandler) Delete(c *gin.Context) { + 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 + } + + c.JSON(http.StatusOK, gin.H{"message": "pricelist deleted"}) +} + +// 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) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"}) + return + } + + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50")) + search := c.Query("search") + + items, total, err := h.service.GetItems(uint(id), page, perPage, search) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "items": items, + "total": total, + "page": page, + "per_page": perPage, + }) +} + +// 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 +func (h *PricelistHandler) GetLatest(c *gin.Context) { + // Try to get from server first + pl, err := h.service.GetLatestActive() + if err != nil { + // If offline or no server pricelists, try to get from local cache + if h.localDB == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "no database available"}) + return + } + localPL, localErr := h.localDB.GetLatestLocalPricelist() + if localErr != nil { + // No local pricelists either + c.JSON(http.StatusNotFound, gin.H{ + "error": "no pricelists available", + "local_error": localErr.Error(), + }) + return + } + // Return local pricelist + c.JSON(http.StatusOK, gin.H{ + "id": localPL.ServerID, + "version": localPL.Version, + "created_by": "sync", + "item_count": 0, // Not tracked in local pricelists + "is_active": true, + "created_at": localPL.CreatedAt, + "synced_from": "local", + }) + return + } + + c.JSON(http.StatusOK, pl) +} diff --git a/internal/handlers/pricing.go b/internal/handlers/pricing.go index 8f0f710..c928bdf 100644 --- a/internal/handlers/pricing.go +++ b/internal/handlers/pricing.go @@ -68,6 +68,17 @@ func NewPricingHandler( } 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) @@ -86,6 +97,19 @@ type ComponentWithCount struct { } 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")) @@ -213,6 +237,15 @@ func (h *PricingHandler) expandMetaPrices(metaPrices, excludeLot string) []strin } 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) @@ -248,6 +281,15 @@ type UpdatePriceRequest struct { } 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()}) @@ -409,6 +451,15 @@ func (h *PricingHandler) recalculateSinglePrice(lotName string) { } 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") @@ -588,6 +639,18 @@ func (h *PricingHandler) RecalculateAll(c *gin.Context) { } 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")) @@ -613,6 +676,15 @@ func (h *PricingHandler) ListAlerts(c *gin.Context) { } 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"}) @@ -628,6 +700,15 @@ func (h *PricingHandler) AcknowledgeAlert(c *gin.Context) { } 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"}) @@ -643,6 +724,15 @@ func (h *PricingHandler) ResolveAlert(c *gin.Context) { } 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"}) @@ -667,6 +757,15 @@ type PreviewPriceRequest struct { } 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()}) @@ -708,8 +807,8 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) { medianAllTime = &median } - // Get quote count (from all relevant lots) - var quoteCount int64 + // Get quote count (from all relevant lots) - total count + var quoteCountTotal int64 for _, lotName := range lotNames { var count int64 if strings.HasSuffix(lotName, "*") { @@ -718,7 +817,25 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) { } else { h.db.Model(&models.LotLog{}).Where("lot = ?", lotName).Count(&count) } - quoteCount += 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) @@ -773,14 +890,15 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{ - "lot_name": req.LotName, - "current_price": comp.CurrentPrice, - "median_all_time": medianAllTime, - "new_price": newPrice, - "quote_count": quoteCount, - "manual_price": comp.ManualPrice, - "last_price": lastPrice.Price, - "last_price_date": lastPrice.Date, + "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, }) } diff --git a/internal/handlers/setup.go b/internal/handlers/setup.go new file mode 100644 index 0000000..2a9c858 --- /dev/null +++ b/internal/handlers/setup.go @@ -0,0 +1,235 @@ +package handlers + +import ( + "fmt" + "html/template" + "log/slog" + "net/http" + "path/filepath" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "git.mchus.pro/mchus/quoteforge/internal/db" + "git.mchus.pro/mchus/quoteforge/internal/localdb" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +type SetupHandler struct { + localDB *localdb.LocalDB + connMgr *db.ConnectionManager + templates map[string]*template.Template + restartSig chan struct{} +} + +func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, templatesPath string, restartSig chan struct{}) (*SetupHandler, error) { + funcMap := template.FuncMap{ + "sub": func(a, b int) int { return a - b }, + "add": func(a, b int) int { return a + b }, + } + + templates := make(map[string]*template.Template) + + // Load setup template (standalone, no base needed) + setupPath := filepath.Join(templatesPath, "setup.html") + tmpl, err := template.New("").Funcs(funcMap).ParseFiles(setupPath) + if err != nil { + return nil, fmt.Errorf("parsing setup template: %w", err) + } + templates["setup.html"] = tmpl + + return &SetupHandler{ + localDB: localDB, + connMgr: connMgr, + templates: templates, + restartSig: restartSig, + }, nil +} + +// ShowSetup renders the database setup form +func (h *SetupHandler) ShowSetup(c *gin.Context) { + c.Header("Content-Type", "text/html; charset=utf-8") + + // Get existing settings if any + settings, _ := h.localDB.GetSettings() + + data := gin.H{ + "Settings": settings, + } + + tmpl := h.templates["setup.html"] + if err := tmpl.ExecuteTemplate(c.Writer, "setup.html", data); err != nil { + c.String(http.StatusInternalServerError, "Template error: %v", err) + } +} + +// TestConnection tests the database connection without saving +func (h *SetupHandler) TestConnection(c *gin.Context) { + host := c.PostForm("host") + portStr := c.PostForm("port") + database := c.PostForm("database") + user := c.PostForm("user") + password := c.PostForm("password") + + port := 3306 + if p, err := strconv.Atoi(portStr); err == nil { + port = p + } + + // If password is empty, try to use saved password + if password == "" { + if settings, err := h.localDB.GetSettings(); err == nil && settings != nil { + password = settings.PasswordEncrypted // GetSettings returns decrypted password in this field + } + } + + dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s", + user, password, host, port, database) + + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "error": fmt.Sprintf("Connection failed: %v", err), + }) + return + } + + sqlDB, err := db.DB() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "error": fmt.Sprintf("Failed to get database handle: %v", err), + }) + return + } + defer sqlDB.Close() + + if err := sqlDB.Ping(); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "error": fmt.Sprintf("Ping failed: %v", err), + }) + return + } + + // Check for required tables + var lotCount int64 + if err := db.Table("lot").Count(&lotCount).Error; err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "error": fmt.Sprintf("Table 'lot' not found or inaccessible: %v", err), + }) + return + } + + // Check write permission + canWrite := testWritePermission(db) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "lot_count": lotCount, + "can_write": canWrite, + "message": fmt.Sprintf("Connected successfully! Found %d components.", lotCount), + }) +} + +// SaveConnection saves the connection settings and signals restart +func (h *SetupHandler) SaveConnection(c *gin.Context) { + host := c.PostForm("host") + portStr := c.PostForm("port") + database := c.PostForm("database") + user := c.PostForm("user") + password := c.PostForm("password") + + port := 3306 + if p, err := strconv.Atoi(portStr); err == nil { + port = p + } + + // If password is empty, use saved password + if password == "" { + if settings, err := h.localDB.GetSettings(); err == nil && settings != nil { + password = settings.PasswordEncrypted // GetSettings returns decrypted password in this field + } + } + + // Test connection first + dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s", + user, password, host, port, database) + + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "error": fmt.Sprintf("Connection failed: %v", err), + }) + return + } + + sqlDB, _ := db.DB() + sqlDB.Close() + + // Save settings + if err := h.localDB.SaveSettings(host, port, database, user, password); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "error": fmt.Sprintf("Failed to save settings: %v", err), + }) + return + } + + // Try to connect immediately to verify settings + if h.connMgr != nil { + if err := h.connMgr.TryConnect(); err != nil { + slog.Warn("failed to connect after saving settings", "error", err) + } else { + slog.Info("successfully connected to database after saving settings") + } + } + + // Always restart to properly initialize all services with the new connection + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Settings saved. Please restart the application to apply changes.", + "restart_required": true, + }) + + // Signal restart after response is sent (if restart signal is configured) + if h.restartSig != nil { + go func() { + time.Sleep(500 * time.Millisecond) // Give time for response to be sent + h.restartSig <- struct{}{} + }() + } +} + +// GetStatus returns the current setup status +func (h *SetupHandler) GetStatus(c *gin.Context) { + hasSettings := h.localDB.HasSettings() + c.JSON(http.StatusOK, gin.H{ + "configured": hasSettings, + }) +} + +func testWritePermission(db *gorm.DB) bool { + // Simple check: try to create a temporary table and drop it + testTable := fmt.Sprintf("qt_write_test_%d", time.Now().UnixNano()) + + // Try to create a test table + err := db.Exec(fmt.Sprintf("CREATE TABLE %s (id INT)", testTable)).Error + if err != nil { + return false + } + + // Drop it immediately + db.Exec(fmt.Sprintf("DROP TABLE %s", testTable)) + + return true +} diff --git a/internal/handlers/sync.go b/internal/handlers/sync.go new file mode 100644 index 0000000..597483f --- /dev/null +++ b/internal/handlers/sync.go @@ -0,0 +1,388 @@ +package handlers + +import ( + "html/template" + "log/slog" + "net/http" + "path/filepath" + "time" + + "github.com/gin-gonic/gin" + "git.mchus.pro/mchus/quoteforge/internal/db" + "git.mchus.pro/mchus/quoteforge/internal/localdb" + "git.mchus.pro/mchus/quoteforge/internal/services/sync" +) + +// SyncHandler handles sync API endpoints +type SyncHandler struct { + localDB *localdb.LocalDB + syncService *sync.Service + connMgr *db.ConnectionManager + tmpl *template.Template +} + +// NewSyncHandler creates a new sync handler +func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, templatesPath string) (*SyncHandler, error) { + // Load sync_status partial template + partialPath := filepath.Join(templatesPath, "partials", "sync_status.html") + tmpl, err := template.ParseFiles(partialPath) + if err != nil { + return nil, err + } + + return &SyncHandler{ + localDB: localDB, + syncService: syncService, + connMgr: connMgr, + tmpl: tmpl, + }, nil +} + +// 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"` +} + +// GetStatus returns current sync status +// GET /api/sync/status +func (h *SyncHandler) GetStatus(c *gin.Context) { + // Check online status by pinging MariaDB + isOnline := h.checkOnline() + + // Get sync times + lastComponentSync := h.localDB.GetComponentSyncTime() + lastPricelistSync := h.localDB.GetLastSyncTime() + + // Get counts + componentsCount := h.localDB.CountLocalComponents() + pricelistsCount := h.localDB.CountLocalPricelists() + + // Get server pricelist count if online + serverPricelists := 0 + needPricelistSync := false + if isOnline { + status, err := h.syncService.GetStatus() + if err == nil { + serverPricelists = status.ServerPricelists + needPricelistSync = status.NeedsSync + } + } + + // Check if component sync is needed (older than 24 hours) + needComponentSync := h.localDB.NeedComponentSync(24) + + c.JSON(http.StatusOK, SyncStatusResponse{ + LastComponentSync: lastComponentSync, + LastPricelistSync: lastPricelistSync, + IsOnline: isOnline, + ComponentsCount: componentsCount, + PricelistsCount: pricelistsCount, + ServerPricelists: serverPricelists, + NeedComponentSync: needComponentSync, + NeedPricelistSync: needPricelistSync, + }) +} + +// SyncResultResponse represents sync operation result +type SyncResultResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Synced int `json:"synced"` + Duration string `json:"duration"` +} + +// 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", + }) + return + } + + // Get database connection from ConnectionManager + mariaDB, err := h.connMgr.GetDB() + if err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{ + "success": false, + "error": "Database connection failed: " + err.Error(), + }) + return + } + + result, err := h.localDB.SyncComponents(mariaDB) + if err != nil { + slog.Error("component sync failed", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, SyncResultResponse{ + Success: true, + Message: "Components synced successfully", + Synced: result.TotalSynced, + Duration: result.Duration.String(), + }) +} + +// 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", + }) + return + } + + startTime := time.Now() + synced, err := h.syncService.SyncPricelists() + if err != nil { + slog.Error("pricelist sync failed", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, SyncResultResponse{ + Success: true, + Message: "Pricelists synced successfully", + Synced: synced, + Duration: time.Since(startTime).String(), + }) +} + +// SyncAllResponse represents result of full sync +type SyncAllResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + ComponentsSynced int `json:"components_synced"` + PricelistsSynced int `json:"pricelists_synced"` + Duration string `json:"duration"` +} + +// SyncAll syncs both components and pricelists +// 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", + }) + return + } + + startTime := time.Now() + var componentsSynced, pricelistsSynced int + + // Sync components + mariaDB, err := h.connMgr.GetDB() + if err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{ + "success": false, + "error": "Database connection failed: " + err.Error(), + }) + return + } + + compResult, err := h.localDB.SyncComponents(mariaDB) + if err != nil { + slog.Error("component sync failed during full sync", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "error": "Component sync failed: " + err.Error(), + }) + return + } + componentsSynced = compResult.TotalSynced + + // Sync pricelists + pricelistsSynced, err = h.syncService.SyncPricelists() + if err != nil { + slog.Error("pricelist sync failed during full sync", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "error": "Pricelist sync failed: " + err.Error(), + "components_synced": componentsSynced, + }) + return + } + + c.JSON(http.StatusOK, SyncAllResponse{ + Success: true, + Message: "Full sync completed successfully", + ComponentsSynced: componentsSynced, + PricelistsSynced: pricelistsSynced, + Duration: time.Since(startTime).String(), + }) +} + +// checkOnline checks if MariaDB is accessible +func (h *SyncHandler) checkOnline() bool { + return h.connMgr.IsOnline() +} + +// 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", + }) + return + } + + startTime := time.Now() + pushed, err := h.syncService.PushPendingChanges() + if err != nil { + slog.Error("push pending changes failed", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, SyncResultResponse{ + Success: true, + Message: "Pending changes pushed successfully", + Synced: pushed, + Duration: time.Since(startTime).String(), + }) +} + +// GetPendingCount returns the number of pending changes +// GET /api/sync/pending/count +func (h *SyncHandler) GetPendingCount(c *gin.Context) { + count := h.localDB.GetPendingCount() + c.JSON(http.StatusOK, gin.H{ + "count": count, + }) +} + +// GetPendingChanges returns all pending changes +// GET /api/sync/pending +func (h *SyncHandler) GetPendingChanges(c *gin.Context) { + changes, err := h.localDB.GetPendingChanges() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "changes": changes, + }) +} + +// SyncInfoResponse represents sync information +type SyncInfoResponse struct { + LastSyncAt *time.Time `json:"last_sync_at"` + IsOnline bool `json:"is_online"` + ErrorCount int `json:"error_count"` + Errors []SyncError `json:"errors,omitempty"` +} + +// SyncError represents a sync error +type SyncError struct { + Timestamp time.Time `json:"timestamp"` + Message string `json:"message"` +} + +// GetInfo returns sync information for modal +// GET /api/sync/info +func (h *SyncHandler) GetInfo(c *gin.Context) { + // Check online status by pinging MariaDB + isOnline := h.checkOnline() + + // Get sync times + lastPricelistSync := h.localDB.GetLastSyncTime() + + // Get error count (only changes with LastError != "") + errorCount := int(h.localDB.CountErroredChanges()) + + // Get recent errors (last 10) + changes, err := h.localDB.GetPendingChanges() + if err != nil { + slog.Error("failed to get pending changes for sync info", "error", err) + // Even if we can't get changes, we can still return the error count + c.JSON(http.StatusOK, SyncInfoResponse{ + LastSyncAt: lastPricelistSync, + IsOnline: isOnline, + ErrorCount: errorCount, + Errors: []SyncError{}, // Return empty errors list + }) + return + } + + var errors []SyncError + for _, change := range changes { + // Check if there's a last error and it's not empty + if change.LastError != "" { + errors = append(errors, SyncError{ + Timestamp: change.CreatedAt, + Message: change.LastError, + }) + } + } + + // Limit to last 10 errors + if len(errors) > 10 { + errors = errors[:10] + } + + c.JSON(http.StatusOK, SyncInfoResponse{ + LastSyncAt: lastPricelistSync, + IsOnline: isOnline, + ErrorCount: errorCount, + Errors: errors, + }) +} + +// SyncStatusPartial renders the sync status partial for htmx +// GET /partials/sync-status +func (h *SyncHandler) SyncStatusPartial(c *gin.Context) { + // Check online status from middleware + isOfflineValue, exists := c.Get("is_offline") + isOffline := false + if exists { + isOffline = isOfflineValue.(bool) + } else { + // Fallback: check directly if middleware didn't set it + isOffline = !h.checkOnline() + slog.Warn("is_offline not found in context, checking directly") + } + + // Get pending count + pendingCount := h.localDB.GetPendingCount() + + slog.Debug("rendering sync status", "is_offline", isOffline, "pending_count", pendingCount) + + data := gin.H{ + "IsOffline": isOffline, + "PendingCount": pendingCount, + } + + c.Header("Content-Type", "text/html; charset=utf-8") + if err := h.tmpl.ExecuteTemplate(c.Writer, "sync_status", data); err != nil { + slog.Error("failed to render sync_status template", "error", err) + c.String(http.StatusInternalServerError, "Template error: "+err.Error()) + } +} diff --git a/internal/handlers/web.go b/internal/handlers/web.go index 30cf299..b4a9726 100644 --- a/internal/handlers/web.go +++ b/internal/handlers/web.go @@ -61,7 +61,7 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer basePath := filepath.Join(templatesPath, "base.html") // Load each page template with base - simplePages := []string{"login.html", "configs.html", "admin_pricing.html"} + simplePages := []string{"login.html", "configs.html", "admin_pricing.html", "pricelists.html", "pricelist_detail.html"} for _, page := range simplePages { pagePath := filepath.Join(templatesPath, page) tmpl, err := template.New("").Funcs(funcMap).ParseFiles(basePath, pagePath) @@ -154,6 +154,14 @@ 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"}) +} + +func (h *WebHandler) PricelistDetail(c *gin.Context) { + h.render(c, "pricelist_detail.html", gin.H{"ActivePage": "pricelists"}) +} + // Partials for htmx func (h *WebHandler) ComponentsPartial(c *gin.Context) { diff --git a/internal/localdb/components.go b/internal/localdb/components.go new file mode 100644 index 0000000..0158241 --- /dev/null +++ b/internal/localdb/components.go @@ -0,0 +1,410 @@ +package localdb + +import ( + "fmt" + "log/slog" + "strings" + "time" + + "gorm.io/gorm" +) + +// ComponentFilter for searching with filters +type ComponentFilter struct { + Category string + Search string + HasPrice bool +} + +// ComponentSyncResult contains statistics from component sync +type ComponentSyncResult struct { + TotalSynced int + NewCount int + UpdateCount int + Duration time.Duration +} + +// SyncComponents loads components from MariaDB (lot + qt_lot_metadata) into local_components +func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error) { + startTime := time.Now() + + // Query to join lot with qt_lot_metadata + // Use LEFT JOIN to include lots without metadata + type componentRow struct { + LotName string + LotDescription string + Category *string + Model *string + CurrentPrice *float64 + } + + var rows []componentRow + err := mariaDB.Raw(` + SELECT + l.lot_name, + l.lot_description, + COALESCE(c.code, SUBSTRING_INDEX(l.lot_name, '_', 1)) as category, + m.model, + m.current_price + FROM lot l + LEFT JOIN qt_lot_metadata m ON l.lot_name = m.lot_name + LEFT JOIN qt_categories c ON m.category_id = c.id + WHERE m.is_hidden = FALSE OR m.is_hidden IS NULL + ORDER BY l.lot_name + `).Scan(&rows).Error + if err != nil { + return nil, fmt.Errorf("querying components from MariaDB: %w", err) + } + + if len(rows) == 0 { + slog.Warn("no components found in MariaDB") + return &ComponentSyncResult{ + Duration: time.Since(startTime), + }, nil + } + + // Get existing local components for comparison + existingMap := make(map[string]bool) + var existing []LocalComponent + if err := l.db.Find(&existing).Error; err != nil { + return nil, fmt.Errorf("reading existing local components: %w", err) + } + for _, c := range existing { + existingMap[c.LotName] = true + } + + // Prepare components for batch insert/update + syncTime := time.Now() + components := make([]LocalComponent, 0, len(rows)) + newCount := 0 + + for _, row := range rows { + category := "" + if row.Category != nil { + category = *row.Category + } else { + // Parse category from lot_name (e.g., "CPU_AMD_9654" -> "CPU") + parts := strings.SplitN(row.LotName, "_", 2) + if len(parts) >= 1 { + category = parts[0] + } + } + + model := "" + if row.Model != nil { + model = *row.Model + } + + comp := LocalComponent{ + LotName: row.LotName, + LotDescription: row.LotDescription, + Category: category, + Model: model, + CurrentPrice: row.CurrentPrice, + SyncedAt: syncTime, + } + components = append(components, comp) + + if !existingMap[row.LotName] { + newCount++ + } + } + + // Use transaction for bulk upsert + err = l.db.Transaction(func(tx *gorm.DB) error { + // Delete all existing and insert new (simpler than upsert for SQLite) + if err := tx.Where("1=1").Delete(&LocalComponent{}).Error; err != nil { + return fmt.Errorf("clearing local components: %w", err) + } + + // Batch insert + batchSize := 500 + for i := 0; i < len(components); i += batchSize { + end := i + batchSize + if end > len(components) { + end = len(components) + } + if err := tx.CreateInBatches(components[i:end], batchSize).Error; err != nil { + return fmt.Errorf("inserting components batch: %w", err) + } + } + + return nil + }) + if err != nil { + return nil, err + } + + // Update last sync time + if err := l.SetComponentSyncTime(syncTime); err != nil { + slog.Warn("failed to update component sync time", "error", err) + } + + result := &ComponentSyncResult{ + TotalSynced: len(components), + NewCount: newCount, + UpdateCount: len(components) - newCount, + Duration: time.Since(startTime), + } + + slog.Info("components synced", + "total", result.TotalSynced, + "new", result.NewCount, + "updated", result.UpdateCount, + "duration", result.Duration) + + return result, nil +} + +// SearchLocalComponents searches components in local cache by query string +// Searches in lot_name, lot_description, category, and model fields +func (l *LocalDB) SearchLocalComponents(query string, limit int) ([]LocalComponent, error) { + if limit <= 0 { + limit = 50 + } + + var components []LocalComponent + + if query == "" { + // Return all components with limit + err := l.db.Order("lot_name").Limit(limit).Find(&components).Error + return components, err + } + + // Search with LIKE on multiple fields + searchPattern := "%" + strings.ToLower(query) + "%" + + err := l.db.Where( + "LOWER(lot_name) LIKE ? OR LOWER(lot_description) LIKE ? OR LOWER(category) LIKE ? OR LOWER(model) LIKE ?", + searchPattern, searchPattern, searchPattern, searchPattern, + ).Order("lot_name").Limit(limit).Find(&components).Error + + return components, err +} + +// SearchLocalComponentsByCategory searches components by category and optional query +func (l *LocalDB) SearchLocalComponentsByCategory(category string, query string, limit int) ([]LocalComponent, error) { + if limit <= 0 { + limit = 50 + } + + var components []LocalComponent + db := l.db.Where("LOWER(category) = ?", strings.ToLower(category)) + + if query != "" { + searchPattern := "%" + strings.ToLower(query) + "%" + db = db.Where( + "LOWER(lot_name) LIKE ? OR LOWER(lot_description) LIKE ? OR LOWER(model) LIKE ?", + searchPattern, searchPattern, searchPattern, + ) + } + + err := db.Order("lot_name").Limit(limit).Find(&components).Error + return components, err +} + +// ListComponents returns components with filtering and pagination +func (l *LocalDB) ListComponents(filter ComponentFilter, offset, limit int) ([]LocalComponent, int64, error) { + db := l.db + + // Apply category filter + if filter.Category != "" { + db = db.Where("LOWER(category) = ?", strings.ToLower(filter.Category)) + } + + // Apply search filter + if filter.Search != "" { + searchPattern := "%" + strings.ToLower(filter.Search) + "%" + db = db.Where( + "LOWER(lot_name) LIKE ? OR LOWER(lot_description) LIKE ? OR LOWER(category) LIKE ? OR LOWER(model) LIKE ?", + searchPattern, searchPattern, searchPattern, searchPattern, + ) + } + + // Apply price filter + if filter.HasPrice { + db = db.Where("current_price IS NOT NULL") + } + + // Get total count + var total int64 + if err := db.Model(&LocalComponent{}).Count(&total).Error; err != nil { + return nil, 0, err + } + + // Apply pagination and get results + var components []LocalComponent + if err := db.Order("lot_name").Offset(offset).Limit(limit).Find(&components).Error; err != nil { + return nil, 0, err + } + + return components, total, nil +} + +// GetLocalComponent returns a single component by lot_name +func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) { + var component LocalComponent + err := l.db.Where("lot_name = ?", lotName).First(&component).Error + if err != nil { + return nil, err + } + return &component, nil +} + +// GetLocalComponentCategories returns distinct categories from local components +func (l *LocalDB) GetLocalComponentCategories() ([]string, error) { + var categories []string + err := l.db.Model(&LocalComponent{}). + Distinct("category"). + Where("category != ''"). + Order("category"). + Pluck("category", &categories).Error + return categories, err +} + +// CountLocalComponents returns the total number of local components +func (l *LocalDB) CountLocalComponents() int64 { + var count int64 + l.db.Model(&LocalComponent{}).Count(&count) + return count +} + +// CountLocalComponentsByCategory returns component count by category +func (l *LocalDB) CountLocalComponentsByCategory(category string) int64 { + var count int64 + l.db.Model(&LocalComponent{}).Where("LOWER(category) = ?", strings.ToLower(category)).Count(&count) + return count +} + +// GetComponentSyncTime returns the last component sync timestamp +func (l *LocalDB) GetComponentSyncTime() *time.Time { + var setting struct { + Value string + } + if err := l.db.Table("app_settings"). + Where("key = ?", "last_component_sync"). + First(&setting).Error; err != nil { + return nil + } + + t, err := time.Parse(time.RFC3339, setting.Value) + if err != nil { + return nil + } + return &t +} + +// SetComponentSyncTime sets the last component sync timestamp +func (l *LocalDB) SetComponentSyncTime(t time.Time) error { + return l.db.Exec(` + INSERT INTO app_settings (key, value, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at + `, "last_component_sync", t.Format(time.RFC3339), time.Now().Format(time.RFC3339)).Error +} + +// NeedComponentSync checks if component sync is needed (older than specified hours) +func (l *LocalDB) NeedComponentSync(maxAgeHours int) bool { + syncTime := l.GetComponentSyncTime() + if syncTime == nil { + return true + } + return time.Since(*syncTime).Hours() > float64(maxAgeHours) +} + +// UpdateComponentPricesFromPricelist updates current_price in local_components from pricelist items +// This allows offline price updates using synced pricelists without MariaDB connection +func (l *LocalDB) UpdateComponentPricesFromPricelist(pricelistID uint) (int, error) { + // Get all items from the specified pricelist + var items []LocalPricelistItem + if err := l.db.Where("pricelist_id = ?", pricelistID).Find(&items).Error; err != nil { + return 0, fmt.Errorf("fetching pricelist items: %w", err) + } + + if len(items) == 0 { + slog.Warn("no items found in pricelist", "pricelist_id", pricelistID) + return 0, nil + } + + // Update current_price for each component + updated := 0 + err := l.db.Transaction(func(tx *gorm.DB) error { + for _, item := range items { + result := tx.Model(&LocalComponent{}). + Where("lot_name = ?", item.LotName). + Update("current_price", item.Price) + + if result.Error != nil { + return fmt.Errorf("updating price for %s: %w", item.LotName, result.Error) + } + + if result.RowsAffected > 0 { + updated++ + } + } + return nil + }) + + if err != nil { + return 0, err + } + + slog.Info("updated component prices from pricelist", + "pricelist_id", pricelistID, + "total_items", len(items), + "updated_components", updated) + + return updated, nil +} + +// EnsureComponentPricesFromPricelists loads prices from the latest pricelist into local_components +// if no components exist or all current prices are NULL +func (l *LocalDB) EnsureComponentPricesFromPricelists() error { + // Check if we have any components with prices + var count int64 + if err := l.db.Model(&LocalComponent{}).Where("current_price IS NOT NULL").Count(&count).Error; err != nil { + return fmt.Errorf("checking component prices: %w", err) + } + + // If we have components with prices, don't load from pricelists + if count > 0 { + return nil + } + + // Check if we have any components at all + var totalComponents int64 + if err := l.db.Model(&LocalComponent{}).Count(&totalComponents).Error; err != nil { + return fmt.Errorf("counting components: %w", err) + } + + // If we have no components, we need to load them from pricelists + if totalComponents == 0 { + slog.Info("no components found in local database, loading from latest pricelist") + // This would typically be called from the sync service or setup process + // For now, we'll just return nil to indicate no action needed + return nil + } + + // If we have components but no prices, we should load prices from pricelists + // Find the latest pricelist + var latestPricelist LocalPricelist + if err := l.db.Order("created_at DESC").First(&latestPricelist).Error; err != nil { + if err == gorm.ErrRecordNotFound { + slog.Warn("no pricelists found in local database") + return nil + } + return fmt.Errorf("finding latest pricelist: %w", err) + } + + // Update prices from the latest pricelist + updated, err := l.UpdateComponentPricesFromPricelist(latestPricelist.ID) + if err != nil { + return fmt.Errorf("updating component prices from pricelist: %w", err) + } + + slog.Info("loaded component prices from latest pricelist", + "pricelist_id", latestPricelist.ID, + "updated_components", updated) + + return nil +} diff --git a/internal/localdb/converters.go b/internal/localdb/converters.go new file mode 100644 index 0000000..a1501bd --- /dev/null +++ b/internal/localdb/converters.go @@ -0,0 +1,163 @@ +package localdb + +import ( + "time" + + "git.mchus.pro/mchus/quoteforge/internal/models" +) + +// ConfigurationToLocal converts models.Configuration to LocalConfiguration +func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration { + items := make(LocalConfigItems, len(cfg.Items)) + for i, item := range cfg.Items { + items[i] = LocalConfigItem{ + LotName: item.LotName, + Quantity: item.Quantity, + UnitPrice: item.UnitPrice, + } + } + + local := &LocalConfiguration{ + UUID: cfg.UUID, + Name: cfg.Name, + Items: items, + TotalPrice: cfg.TotalPrice, + CustomPrice: cfg.CustomPrice, + Notes: cfg.Notes, + IsTemplate: cfg.IsTemplate, + ServerCount: cfg.ServerCount, + PriceUpdatedAt: cfg.PriceUpdatedAt, + CreatedAt: cfg.CreatedAt, + UpdatedAt: time.Now(), + SyncStatus: "pending", + OriginalUserID: cfg.UserID, + } + + if cfg.ID > 0 { + serverID := cfg.ID + local.ServerID = &serverID + } + + return local +} + +// LocalToConfiguration converts LocalConfiguration to models.Configuration +func LocalToConfiguration(local *LocalConfiguration) *models.Configuration { + items := make(models.ConfigItems, len(local.Items)) + for i, item := range local.Items { + items[i] = models.ConfigItem{ + LotName: item.LotName, + Quantity: item.Quantity, + UnitPrice: item.UnitPrice, + } + } + + cfg := &models.Configuration{ + UUID: local.UUID, + UserID: local.OriginalUserID, + Name: local.Name, + Items: items, + TotalPrice: local.TotalPrice, + CustomPrice: local.CustomPrice, + Notes: local.Notes, + IsTemplate: local.IsTemplate, + ServerCount: local.ServerCount, + PriceUpdatedAt: local.PriceUpdatedAt, + CreatedAt: local.CreatedAt, + } + + if local.ServerID != nil { + cfg.ID = *local.ServerID + } + + return cfg +} + +// PricelistToLocal converts models.Pricelist to LocalPricelist +func PricelistToLocal(pl *models.Pricelist) *LocalPricelist { + name := pl.Notification + if name == "" { + name = pl.Version + } + + return &LocalPricelist{ + ServerID: pl.ID, + Version: pl.Version, + Name: name, + CreatedAt: pl.CreatedAt, + SyncedAt: time.Now(), + IsUsed: false, + } +} + +// LocalToPricelist converts LocalPricelist to models.Pricelist +func LocalToPricelist(local *LocalPricelist) *models.Pricelist { + return &models.Pricelist{ + ID: local.ServerID, + Version: local.Version, + Notification: local.Name, + CreatedAt: local.CreatedAt, + IsActive: true, + } +} + +// PricelistItemToLocal converts models.PricelistItem to LocalPricelistItem +func PricelistItemToLocal(item *models.PricelistItem, localPricelistID uint) *LocalPricelistItem { + return &LocalPricelistItem{ + PricelistID: localPricelistID, + LotName: item.LotName, + Price: item.Price, + } +} + +// LocalToPricelistItem converts LocalPricelistItem to models.PricelistItem +func LocalToPricelistItem(local *LocalPricelistItem, serverPricelistID uint) *models.PricelistItem { + return &models.PricelistItem{ + ID: local.ID, + PricelistID: serverPricelistID, + LotName: local.LotName, + Price: local.Price, + } +} + +// ComponentToLocal converts models.LotMetadata to LocalComponent +func ComponentToLocal(meta *models.LotMetadata) *LocalComponent { + var lotDesc string + var category string + + if meta.Lot != nil { + lotDesc = meta.Lot.LotDescription + } + + // Extract category from lot_name (e.g., "CPU_AMD_9654" -> "CPU") + if len(meta.LotName) > 0 { + for i, ch := range meta.LotName { + if ch == '_' { + category = meta.LotName[:i] + break + } + } + } + + return &LocalComponent{ + LotName: meta.LotName, + LotDescription: lotDesc, + Category: category, + Model: meta.Model, + CurrentPrice: meta.CurrentPrice, + SyncedAt: time.Now(), + } +} + +// LocalToComponent converts LocalComponent to models.LotMetadata +func LocalToComponent(local *LocalComponent) *models.LotMetadata { + return &models.LotMetadata{ + LotName: local.LotName, + Model: local.Model, + CurrentPrice: local.CurrentPrice, + Lot: &models.Lot{ + LotName: local.LotName, + LotDescription: local.LotDescription, + }, + } +} diff --git a/internal/localdb/encryption.go b/internal/localdb/encryption.go new file mode 100644 index 0000000..8e25a52 --- /dev/null +++ b/internal/localdb/encryption.go @@ -0,0 +1,87 @@ +package localdb + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "io" + "os" +) + +// getEncryptionKey derives a 32-byte key from environment variable or machine ID +func getEncryptionKey() []byte { + key := os.Getenv("QUOTEFORGE_ENCRYPTION_KEY") + if key == "" { + // Fallback to a machine-based key (hostname + fixed salt) + hostname, _ := os.Hostname() + key = hostname + "quoteforge-salt-2024" + } + // Hash to get exactly 32 bytes for AES-256 + hash := sha256.Sum256([]byte(key)) + return hash[:] +} + +// Encrypt encrypts plaintext using AES-256-GCM +func Encrypt(plaintext string) (string, error) { + if plaintext == "" { + return "", nil + } + + key := getEncryptionKey() + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + + ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// Decrypt decrypts ciphertext that was encrypted with Encrypt +func Decrypt(ciphertext string) (string, error) { + if ciphertext == "" { + return "", nil + } + + key := getEncryptionKey() + data, err := base64.StdEncoding.DecodeString(ciphertext) + if err != nil { + return "", err + } + + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonceSize := gcm.NonceSize() + if len(data) < nonceSize { + return "", errors.New("ciphertext too short") + } + + nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil) + if err != nil { + return "", err + } + + return string(plaintext), nil +} diff --git a/internal/localdb/localdb.go b/internal/localdb/localdb.go new file mode 100644 index 0000000..1591e51 --- /dev/null +++ b/internal/localdb/localdb.go @@ -0,0 +1,421 @@ +package localdb + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "time" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// ConnectionSettings stores MariaDB connection credentials +type ConnectionSettings struct { + ID uint `gorm:"primaryKey"` + Host string `gorm:"not null"` + Port int `gorm:"not null;default:3306"` + Database string `gorm:"not null"` + User string `gorm:"not null"` + PasswordEncrypted string `gorm:"not null"` // AES encrypted + UpdatedAt time.Time `gorm:"autoUpdateTime"` +} + +func (ConnectionSettings) TableName() string { + return "connection_settings" +} + +// LocalDB manages the local SQLite database for settings +type LocalDB struct { + db *gorm.DB + path string +} + +// New creates a new LocalDB instance +func New(dbPath string) (*LocalDB, error) { + // Ensure directory exists + dir := filepath.Dir(dbPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("creating data directory: %w", err) + } + + db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + return nil, fmt.Errorf("opening sqlite database: %w", err) + } + + // Auto-migrate all local tables + if err := db.AutoMigrate( + &ConnectionSettings{}, + &LocalConfiguration{}, + &LocalPricelist{}, + &LocalPricelistItem{}, + &LocalComponent{}, + &AppSetting{}, + &PendingChange{}, + ); err != nil { + return nil, fmt.Errorf("migrating sqlite database: %w", err) + } + + slog.Info("local SQLite database initialized", "path", dbPath) + + return &LocalDB{ + db: db, + path: dbPath, + }, nil +} + +// HasSettings returns true if connection settings exist +func (l *LocalDB) HasSettings() bool { + var count int64 + l.db.Model(&ConnectionSettings{}).Count(&count) + return count > 0 +} + +// GetSettings retrieves the connection settings with decrypted password +func (l *LocalDB) GetSettings() (*ConnectionSettings, error) { + var settings ConnectionSettings + if err := l.db.First(&settings).Error; err != nil { + return nil, fmt.Errorf("getting settings: %w", err) + } + + // Decrypt password + password, err := Decrypt(settings.PasswordEncrypted) + if err != nil { + return nil, fmt.Errorf("decrypting password: %w", err) + } + settings.PasswordEncrypted = password // Return decrypted password in this field + + return &settings, nil +} + +// SaveSettings saves connection settings with encrypted password +func (l *LocalDB) SaveSettings(host string, port int, database, user, password string) error { + // Encrypt password + encrypted, err := Encrypt(password) + if err != nil { + return fmt.Errorf("encrypting password: %w", err) + } + + settings := ConnectionSettings{ + ID: 1, // Always use ID=1 for single settings row + Host: host, + Port: port, + Database: database, + User: user, + PasswordEncrypted: encrypted, + } + + // Upsert: create or update + result := l.db.Save(&settings) + if result.Error != nil { + return fmt.Errorf("saving settings: %w", result.Error) + } + + slog.Info("connection settings saved", "host", host, "port", port, "database", database, "user", user) + return nil +} + +// DeleteSettings removes all connection settings +func (l *LocalDB) DeleteSettings() error { + return l.db.Where("1=1").Delete(&ConnectionSettings{}).Error +} + +// GetDSN returns the MariaDB DSN string +func (l *LocalDB) GetDSN() (string, error) { + settings, err := l.GetSettings() + if err != nil { + return "", err + } + + // Add aggressive timeouts for offline-first architecture + // timeout: connection establishment timeout (3s) + // readTimeout: I/O read timeout (3s) + // writeTimeout: I/O write timeout (3s) + dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=3s&readTimeout=3s&writeTimeout=3s", + settings.User, + settings.PasswordEncrypted, // Contains decrypted password after GetSettings + settings.Host, + settings.Port, + settings.Database, + ) + + return dsn, nil +} + +// DB returns the underlying gorm.DB for advanced operations +func (l *LocalDB) DB() *gorm.DB { + return l.db +} + +// Close closes the database connection +func (l *LocalDB) Close() error { + sqlDB, err := l.db.DB() + if err != nil { + return err + } + return sqlDB.Close() +} + +// GetDBUser returns the database username from settings +func (l *LocalDB) GetDBUser() string { + settings, err := l.GetSettings() + if err != nil { + return "" + } + return settings.User +} + +// Configuration methods + +// SaveConfiguration saves a configuration to local SQLite +func (l *LocalDB) SaveConfiguration(config *LocalConfiguration) error { + return l.db.Save(config).Error +} + +// GetConfigurations returns all local configurations +func (l *LocalDB) GetConfigurations() ([]LocalConfiguration, error) { + var configs []LocalConfiguration + err := l.db.Order("created_at DESC").Find(&configs).Error + return configs, err +} + +// GetConfigurationByUUID returns a configuration by UUID +func (l *LocalDB) GetConfigurationByUUID(uuid string) (*LocalConfiguration, error) { + var config LocalConfiguration + err := l.db.Where("uuid = ?", uuid).First(&config).Error + return &config, err +} + +// DeleteConfiguration deletes a configuration by UUID +func (l *LocalDB) DeleteConfiguration(uuid string) error { + return l.db.Where("uuid = ?", uuid).Delete(&LocalConfiguration{}).Error +} + +// CountConfigurations returns the number of local configurations +func (l *LocalDB) CountConfigurations() int64 { + var count int64 + l.db.Model(&LocalConfiguration{}).Count(&count) + return count +} + +// Pricelist methods + +// GetLastSyncTime returns the last sync timestamp +func (l *LocalDB) GetLastSyncTime() *time.Time { + var setting struct { + Value string + } + if err := l.db.Table("app_settings"). + Where("key = ?", "last_pricelist_sync"). + First(&setting).Error; err != nil { + return nil + } + + t, err := time.Parse(time.RFC3339, setting.Value) + if err != nil { + return nil + } + return &t +} + +// SetLastSyncTime sets the last sync timestamp +func (l *LocalDB) SetLastSyncTime(t time.Time) error { + // Using raw SQL for upsert since SQLite doesn't have native UPSERT in all versions + return l.db.Exec(` + INSERT INTO app_settings (key, value, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at + `, "last_pricelist_sync", t.Format(time.RFC3339), time.Now().Format(time.RFC3339)).Error +} + +// CountLocalPricelists returns the number of local pricelists +func (l *LocalDB) CountLocalPricelists() int64 { + var count int64 + l.db.Model(&LocalPricelist{}).Count(&count) + return count +} + +// GetLatestLocalPricelist returns the most recently synced pricelist +func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) { + var pricelist LocalPricelist + if err := l.db.Order("created_at DESC").First(&pricelist).Error; err != nil { + return nil, err + } + return &pricelist, nil +} + +// GetLocalPricelistByServerID returns a local pricelist by its server ID +func (l *LocalDB) GetLocalPricelistByServerID(serverID uint) (*LocalPricelist, error) { + var pricelist LocalPricelist + if err := l.db.Where("server_id = ?", serverID).First(&pricelist).Error; err != nil { + return nil, err + } + return &pricelist, nil +} + +// GetLocalPricelistByID returns a local pricelist by its local ID +func (l *LocalDB) GetLocalPricelistByID(id uint) (*LocalPricelist, error) { + var pricelist LocalPricelist + if err := l.db.First(&pricelist, id).Error; err != nil { + return nil, err + } + return &pricelist, nil +} + +// SaveLocalPricelist saves a pricelist to local SQLite +func (l *LocalDB) SaveLocalPricelist(pricelist *LocalPricelist) error { + return l.db.Save(pricelist).Error +} + +// GetLocalPricelists returns all local pricelists +func (l *LocalDB) GetLocalPricelists() ([]LocalPricelist, error) { + var pricelists []LocalPricelist + if err := l.db.Order("created_at DESC").Find(&pricelists).Error; err != nil { + return nil, err + } + return pricelists, nil +} + +// CountLocalPricelistItems returns the number of items for a pricelist +func (l *LocalDB) CountLocalPricelistItems(pricelistID uint) int64 { + var count int64 + l.db.Model(&LocalPricelistItem{}).Where("pricelist_id = ?", pricelistID).Count(&count) + return count +} + +// SaveLocalPricelistItems saves pricelist items to local SQLite +func (l *LocalDB) SaveLocalPricelistItems(items []LocalPricelistItem) error { + if len(items) == 0 { + return nil + } + + // Batch insert + batchSize := 500 + for i := 0; i < len(items); i += batchSize { + end := i + batchSize + if end > len(items) { + end = len(items) + } + if err := l.db.CreateInBatches(items[i:end], batchSize).Error; err != nil { + return err + } + } + return nil +} + +// GetLocalPricelistItems returns items for a local pricelist +func (l *LocalDB) GetLocalPricelistItems(pricelistID uint) ([]LocalPricelistItem, error) { + var items []LocalPricelistItem + if err := l.db.Where("pricelist_id = ?", pricelistID).Find(&items).Error; err != nil { + return nil, err + } + return items, nil +} + +// GetLocalPriceForLot returns the price for a lot from a local pricelist +func (l *LocalDB) GetLocalPriceForLot(pricelistID uint, lotName string) (float64, error) { + var item LocalPricelistItem + if err := l.db.Where("pricelist_id = ? AND lot_name = ?", pricelistID, lotName). + First(&item).Error; err != nil { + return 0, err + } + return item.Price, nil +} + +// MarkPricelistAsUsed marks a pricelist as used by a configuration +func (l *LocalDB) MarkPricelistAsUsed(pricelistID uint, isUsed bool) error { + return l.db.Model(&LocalPricelist{}).Where("id = ?", pricelistID). + Update("is_used", isUsed).Error +} + +// DeleteLocalPricelist deletes a pricelist and its items +func (l *LocalDB) DeleteLocalPricelist(id uint) error { + // Delete items first + if err := l.db.Where("pricelist_id = ?", id).Delete(&LocalPricelistItem{}).Error; err != nil { + return err + } + // Delete pricelist + return l.db.Delete(&LocalPricelist{}, id).Error +} + +// PendingChange methods + +// AddPendingChange adds a change to the sync queue +func (l *LocalDB) AddPendingChange(entityType, entityUUID, operation, payload string) error { + change := PendingChange{ + EntityType: entityType, + EntityUUID: entityUUID, + Operation: operation, + Payload: payload, + CreatedAt: time.Now(), + Attempts: 0, + } + return l.db.Create(&change).Error +} + +// GetPendingChanges returns all pending changes ordered by creation time +func (l *LocalDB) GetPendingChanges() ([]PendingChange, error) { + var changes []PendingChange + err := l.db.Order("created_at ASC").Find(&changes).Error + return changes, err +} + +// GetPendingChangesByEntity returns pending changes for a specific entity +func (l *LocalDB) GetPendingChangesByEntity(entityType, entityUUID string) ([]PendingChange, error) { + var changes []PendingChange + err := l.db.Where("entity_type = ? AND entity_uuid = ?", entityType, entityUUID). + Order("created_at ASC").Find(&changes).Error + return changes, err +} + +// DeletePendingChange removes a change from the sync queue after successful sync +func (l *LocalDB) DeletePendingChange(id int64) error { + return l.db.Delete(&PendingChange{}, id).Error +} + +// IncrementPendingChangeAttempts updates the attempt counter and last error +func (l *LocalDB) IncrementPendingChangeAttempts(id int64, errorMsg string) error { + return l.db.Model(&PendingChange{}).Where("id = ?", id).Updates(map[string]interface{}{ + "attempts": gorm.Expr("attempts + 1"), + "last_error": errorMsg, + }).Error +} + +// CountPendingChanges returns the total number of pending changes +func (l *LocalDB) CountPendingChanges() int64 { + var count int64 + l.db.Model(&PendingChange{}).Count(&count) + return count +} + +// CountPendingChangesByType returns the number of pending changes by entity type +func (l *LocalDB) CountPendingChangesByType(entityType string) int64 { + var count int64 + l.db.Model(&PendingChange{}).Where("entity_type = ?", entityType).Count(&count) + return count +} + +// CountErroredChanges returns the number of pending changes with errors +func (l *LocalDB) CountErroredChanges() int64 { + var count int64 + l.db.Model(&PendingChange{}).Where("last_error != ?", "").Count(&count) + return count +} + +// MarkChangesSynced marks multiple pending changes as synced by deleting them +func (l *LocalDB) MarkChangesSynced(ids []int64) error { + if len(ids) == 0 { + return nil + } + return l.db.Where("id IN ?", ids).Delete(&PendingChange{}).Error +} + +// GetPendingCount returns the total number of pending changes (alias for CountPendingChanges) +func (l *LocalDB) GetPendingCount() int64 { + return l.CountPendingChanges() +} diff --git a/internal/localdb/models.go b/internal/localdb/models.go new file mode 100644 index 0000000..8088fdf --- /dev/null +++ b/internal/localdb/models.go @@ -0,0 +1,139 @@ +package localdb + +import ( + "database/sql/driver" + "encoding/json" + "errors" + "time" +) + +// AppSetting stores application settings in local SQLite +type AppSetting struct { + Key string `gorm:"primaryKey" json:"key"` + Value string `gorm:"not null" json:"value"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (AppSetting) TableName() string { + return "app_settings" +} + +// LocalConfigItem represents an item in a configuration +type LocalConfigItem struct { + LotName string `json:"lot_name"` + Quantity int `json:"quantity"` + UnitPrice float64 `json:"unit_price"` +} + +// LocalConfigItems is a slice of LocalConfigItem that can be stored as JSON +type LocalConfigItems []LocalConfigItem + +func (c LocalConfigItems) Value() (driver.Value, error) { + return json.Marshal(c) +} + +func (c *LocalConfigItems) Scan(value interface{}) error { + if value == nil { + *c = make(LocalConfigItems, 0) + return nil + } + var bytes []byte + switch v := value.(type) { + case []byte: + bytes = v + case string: + bytes = []byte(v) + default: + return errors.New("type assertion failed for LocalConfigItems") + } + return json.Unmarshal(bytes, c) +} + +func (c LocalConfigItems) Total() float64 { + var total float64 + for _, item := range c { + total += item.UnitPrice * float64(item.Quantity) + } + return total +} + +// LocalConfiguration stores configurations in local SQLite +type LocalConfiguration struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + UUID string `gorm:"uniqueIndex;not null" json:"uuid"` + ServerID *uint `json:"server_id"` // ID on MariaDB server, NULL if local only + Name string `gorm:"not null" json:"name"` + Items LocalConfigItems `gorm:"type:text" json:"items"` // JSON stored as text in SQLite + TotalPrice *float64 `json:"total_price"` + CustomPrice *float64 `json:"custom_price"` + Notes string `json:"notes"` + IsTemplate bool `gorm:"default:false" json:"is_template"` + ServerCount int `gorm:"default:1" json:"server_count"` + PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + SyncedAt *time.Time `json:"synced_at"` + SyncStatus string `gorm:"default:'local'" json:"sync_status"` // 'local', 'synced', 'modified' + OriginalUserID uint `json:"original_user_id"` // UserID from MariaDB for reference +} + +func (LocalConfiguration) TableName() string { + return "local_configurations" +} + +// LocalPricelist stores cached pricelists from server +type LocalPricelist struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + ServerID uint `gorm:"not null" json:"server_id"` // ID on MariaDB server + Version string `gorm:"uniqueIndex;not null" json:"version"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + SyncedAt time.Time `json:"synced_at"` + IsUsed bool `gorm:"default:false" json:"is_used"` // Used by any local configuration +} + +func (LocalPricelist) TableName() string { + return "local_pricelists" +} + +// LocalPricelistItem stores pricelist items +type LocalPricelistItem struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + PricelistID uint `gorm:"not null;index" json:"pricelist_id"` + LotName string `gorm:"not null" json:"lot_name"` + Price float64 `gorm:"not null" json:"price"` +} + +func (LocalPricelistItem) TableName() string { + return "local_pricelist_items" +} + +// LocalComponent stores cached components for offline search +type LocalComponent struct { + LotName string `gorm:"primaryKey" json:"lot_name"` + LotDescription string `json:"lot_description"` + Category string `json:"category"` + Model string `json:"model"` + CurrentPrice *float64 `json:"current_price"` + SyncedAt time.Time `json:"synced_at"` +} + +func (LocalComponent) TableName() string { + return "local_components" +} + +// PendingChange stores changes that need to be synced to the server +type PendingChange struct { + ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` + EntityType string `gorm:"not null;index" json:"entity_type"` // "configuration", "project", "specification" + EntityUUID string `gorm:"not null;index" json:"entity_uuid"` + Operation string `gorm:"not null" json:"operation"` // "create", "update", "delete" + Payload string `gorm:"type:text" json:"payload"` // JSON snapshot of the entity + CreatedAt time.Time `gorm:"not null" json:"created_at"` + Attempts int `gorm:"default:0" json:"attempts"` // Retry count for sync + LastError string `gorm:"type:text" json:"last_error,omitempty"` +} + +func (PendingChange) TableName() string { + return "pending_changes" +} diff --git a/internal/middleware/offline.go b/internal/middleware/offline.go new file mode 100644 index 0000000..86e2037 --- /dev/null +++ b/internal/middleware/offline.go @@ -0,0 +1,29 @@ +package middleware + +import ( + "log/slog" + + "github.com/gin-gonic/gin" + "git.mchus.pro/mchus/quoteforge/internal/db" + "git.mchus.pro/mchus/quoteforge/internal/localdb" +) + +// OfflineDetector creates middleware that detects offline mode +// Sets context values: +// - "is_offline" (bool) - true if MariaDB is unavailable +// - "localdb" (*localdb.LocalDB) - local database instance for fallback +func OfflineDetector(connMgr *db.ConnectionManager, local *localdb.LocalDB) gin.HandlerFunc { + return func(c *gin.Context) { + isOffline := !connMgr.IsOnline() + + // Set context values for handlers + c.Set("is_offline", isOffline) + c.Set("localdb", local) + + if isOffline { + slog.Debug("offline mode detected - MariaDB unavailable") + } + + c.Next() + } +} diff --git a/internal/models/models.go b/internal/models/models.go index 7a7fdb9..309e1cb 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -1,6 +1,11 @@ package models -import "gorm.io/gorm" +import ( + "log/slog" + "strings" + + "gorm.io/gorm" +) // AllModels returns all models for auto-migration func AllModels() []interface{} { @@ -12,12 +17,28 @@ func AllModels() []interface{} { &PriceOverride{}, &PricingAlert{}, &ComponentUsageStats{}, + &Pricelist{}, + &PricelistItem{}, } } // Migrate runs auto-migration for all QuoteForge tables +// Handles MySQL constraint errors gracefully for existing tables func Migrate(db *gorm.DB) error { - return db.AutoMigrate(AllModels()...) + for _, model := range AllModels() { + if err := db.AutoMigrate(model); err != nil { + // Skip known MySQL constraint errors for existing tables + errStr := err.Error() + if strings.Contains(errStr, "Can't DROP") || + strings.Contains(errStr, "Duplicate key name") || + strings.Contains(errStr, "check that it exists") { + slog.Warn("migration warning (skipped)", "model", model, "error", errStr) + continue + } + return err + } + } + return nil } // SeedCategories inserts default categories if not exist @@ -49,3 +70,35 @@ func SeedAdminUser(db *gorm.DB, passwordHash string) error { } return db.Create(admin).Error } + +// EnsureDBUser creates or returns the user corresponding to the database connection username. +// This is used when RBAC is disabled - configurations are owned by the DB user. +// Returns the user ID that should be used for all operations. +func EnsureDBUser(db *gorm.DB, dbUsername string) (uint, error) { + if dbUsername == "" { + return 0, nil + } + + var user User + err := db.Where("username = ?", dbUsername).First(&user).Error + if err == nil { + return user.ID, nil + } + + // User doesn't exist, create it + user = User{ + Username: dbUsername, + Email: dbUsername + "@db.local", + PasswordHash: "-", // No password - this is a DB user, not an app user + Role: RoleAdmin, + IsActive: true, + } + + if err := db.Create(&user).Error; err != nil { + slog.Error("failed to create DB user", "username", dbUsername, "error", err) + return 0, err + } + + slog.Info("created DB user for configurations", "username", dbUsername, "user_id", user.ID) + return user.ID, nil +} diff --git a/internal/models/pricelist.go b/internal/models/pricelist.go new file mode 100644 index 0000000..eaf2627 --- /dev/null +++ b/internal/models/pricelist.go @@ -0,0 +1,58 @@ +package models + +import ( + "time" +) + +// Pricelist represents a versioned snapshot of prices +type Pricelist struct { + ID uint `gorm:"primaryKey" json:"id"` + Version string `gorm:"size:20;uniqueIndex;not null" json:"version"` // Format: YYYY-MM-DD-NNN + Notification string `gorm:"size:500" json:"notification"` // Notification shown in configurator + CreatedAt time.Time `json:"created_at"` + CreatedBy string `gorm:"size:100" json:"created_by"` + IsActive bool `gorm:"default:true" json:"is_active"` + UsageCount int `gorm:"default:0" json:"usage_count"` + ExpiresAt *time.Time `json:"expires_at"` + ItemCount int `gorm:"-" json:"item_count,omitempty"` // Virtual field for display +} + +func (Pricelist) TableName() string { + return "qt_pricelists" +} + +// PricelistItem represents a single item in a pricelist +type PricelistItem struct { + ID uint `gorm:"primaryKey" json:"id"` + PricelistID uint `gorm:"not null;index:idx_pricelist_lot" json:"pricelist_id"` + LotName string `gorm:"size:255;not null;index:idx_pricelist_lot" json:"lot_name"` + Price float64 `gorm:"type:decimal(12,2);not null" json:"price"` + PriceMethod string `gorm:"size:20" json:"price_method"` + + // Price calculation settings (snapshot from qt_lot_metadata) + PricePeriodDays int `gorm:"default:90" json:"price_period_days"` + PriceCoefficient float64 `gorm:"type:decimal(5,2);default:0" json:"price_coefficient"` + ManualPrice *float64 `gorm:"type:decimal(12,2)" json:"manual_price,omitempty"` + MetaPrices string `gorm:"size:1000" json:"meta_prices,omitempty"` + + // Virtual fields for display + LotDescription string `gorm:"-" json:"lot_description,omitempty"` + Category string `gorm:"-" json:"category,omitempty"` +} + +func (PricelistItem) TableName() string { + return "qt_pricelist_items" +} + +// PricelistSummary is used for list views +type PricelistSummary struct { + ID uint `json:"id"` + Version string `json:"version"` + Notification string `json:"notification"` + CreatedAt time.Time `json:"created_at"` + CreatedBy string `json:"created_by"` + IsActive bool `json:"is_active"` + UsageCount int `json:"usage_count"` + ExpiresAt *time.Time `json:"expires_at"` + ItemCount int64 `json:"item_count"` +} diff --git a/internal/repository/configuration.go b/internal/repository/configuration.go index 28cef4e..8e9d9ca 100644 --- a/internal/repository/configuration.go +++ b/internal/repository/configuration.go @@ -73,3 +73,18 @@ func (r *ConfigurationRepository) ListTemplates(offset, limit int) ([]models.Con return configs, total, err } + +// ListAll returns all configurations without user filter +func (r *ConfigurationRepository) ListAll(offset, limit int) ([]models.Configuration, int64, error) { + var configs []models.Configuration + var total int64 + + r.db.Model(&models.Configuration{}).Count(&total) + err := r.db. + Order("created_at DESC"). + Offset(offset). + Limit(limit). + Find(&configs).Error + + return configs, total, err +} diff --git a/internal/repository/pricelist.go b/internal/repository/pricelist.go new file mode 100644 index 0000000..82cecf3 --- /dev/null +++ b/internal/repository/pricelist.go @@ -0,0 +1,259 @@ +package repository + +import ( + "fmt" + "strings" + "time" + + "git.mchus.pro/mchus/quoteforge/internal/models" + "gorm.io/gorm" +) + +type PricelistRepository struct { + db *gorm.DB +} + +func NewPricelistRepository(db *gorm.DB) *PricelistRepository { + return &PricelistRepository{db: db} +} + +// List returns pricelists with pagination +func (r *PricelistRepository) List(offset, limit int) ([]models.PricelistSummary, int64, error) { + var total int64 + if err := r.db.Model(&models.Pricelist{}).Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("counting pricelists: %w", err) + } + + var pricelists []models.Pricelist + if err := r.db.Order("created_at DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil { + return nil, 0, fmt.Errorf("listing pricelists: %w", err) + } + + // Get item counts for each pricelist + summaries := make([]models.PricelistSummary, len(pricelists)) + for i, pl := range pricelists { + var itemCount int64 + r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", pl.ID).Count(&itemCount) + + summaries[i] = models.PricelistSummary{ + ID: pl.ID, + Version: pl.Version, + Notification: pl.Notification, + CreatedAt: pl.CreatedAt, + CreatedBy: pl.CreatedBy, + IsActive: pl.IsActive, + UsageCount: pl.UsageCount, + ExpiresAt: pl.ExpiresAt, + ItemCount: itemCount, + } + } + + return summaries, total, nil +} + +// GetByID returns a pricelist by ID +func (r *PricelistRepository) GetByID(id uint) (*models.Pricelist, error) { + var pricelist models.Pricelist + if err := r.db.First(&pricelist, id).Error; err != nil { + return nil, fmt.Errorf("getting pricelist: %w", err) + } + + // Get item count + var itemCount int64 + r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", id).Count(&itemCount) + pricelist.ItemCount = int(itemCount) + + return &pricelist, nil +} + +// GetByVersion returns a pricelist by version string +func (r *PricelistRepository) GetByVersion(version string) (*models.Pricelist, error) { + var pricelist models.Pricelist + if err := r.db.Where("version = ?", version).First(&pricelist).Error; err != nil { + return nil, fmt.Errorf("getting pricelist by version: %w", err) + } + return &pricelist, nil +} + +// GetLatestActive returns the most recent active pricelist +func (r *PricelistRepository) GetLatestActive() (*models.Pricelist, error) { + var pricelist models.Pricelist + if err := r.db.Where("is_active = ?", true).Order("created_at DESC").First(&pricelist).Error; err != nil { + return nil, fmt.Errorf("getting latest pricelist: %w", err) + } + return &pricelist, nil +} + +// Create creates a new pricelist +func (r *PricelistRepository) Create(pricelist *models.Pricelist) error { + if err := r.db.Create(pricelist).Error; err != nil { + return fmt.Errorf("creating pricelist: %w", err) + } + return nil +} + +// Update updates a pricelist +func (r *PricelistRepository) Update(pricelist *models.Pricelist) error { + if err := r.db.Save(pricelist).Error; err != nil { + return fmt.Errorf("updating pricelist: %w", err) + } + return nil +} + +// Delete deletes a pricelist if usage_count is 0 +func (r *PricelistRepository) Delete(id uint) error { + pricelist, err := r.GetByID(id) + if err != nil { + return err + } + + if pricelist.UsageCount > 0 { + return fmt.Errorf("cannot delete pricelist with usage_count > 0 (current: %d)", pricelist.UsageCount) + } + + // Delete items first + if err := r.db.Where("pricelist_id = ?", id).Delete(&models.PricelistItem{}).Error; err != nil { + return fmt.Errorf("deleting pricelist items: %w", err) + } + + // Delete pricelist + if err := r.db.Delete(&models.Pricelist{}, id).Error; err != nil { + return fmt.Errorf("deleting pricelist: %w", err) + } + + return nil +} + +// CreateItems batch inserts pricelist items +func (r *PricelistRepository) CreateItems(items []models.PricelistItem) error { + if len(items) == 0 { + return nil + } + + // Use batch insert for better performance + batchSize := 500 + for i := 0; i < len(items); i += batchSize { + end := i + batchSize + if end > len(items) { + end = len(items) + } + if err := r.db.CreateInBatches(items[i:end], batchSize).Error; err != nil { + return fmt.Errorf("batch inserting pricelist items: %w", err) + } + } + return nil +} + +// GetItems returns pricelist items with pagination +func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, search string) ([]models.PricelistItem, int64, error) { + var total int64 + query := r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", pricelistID) + + if search != "" { + query = query.Where("lot_name LIKE ?", "%"+search+"%") + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("counting pricelist items: %w", err) + } + + var items []models.PricelistItem + if err := query.Order("lot_name").Offset(offset).Limit(limit).Find(&items).Error; err != nil { + return nil, 0, fmt.Errorf("listing pricelist items: %w", err) + } + + // Enrich with lot descriptions + for i := range items { + var lot models.Lot + if err := r.db.Where("lot_name = ?", items[i].LotName).First(&lot).Error; err == nil { + items[i].LotDescription = lot.LotDescription + } + // Parse category from lot_name (e.g., "CPU_AMD_9654" -> "CPU") + parts := strings.SplitN(items[i].LotName, "_", 2) + if len(parts) >= 1 { + items[i].Category = parts[0] + } + } + + return items, total, nil +} + +// GenerateVersion generates a new version string in format YYYY-MM-DD-NNN +func (r *PricelistRepository) GenerateVersion() (string, error) { + today := time.Now().Format("2006-01-02") + + var count int64 + if err := r.db.Model(&models.Pricelist{}). + Where("version LIKE ?", today+"%"). + Count(&count).Error; err != nil { + return "", fmt.Errorf("counting today's pricelists: %w", err) + } + + return fmt.Sprintf("%s-%03d", today, count+1), nil +} + +// CanWrite checks if the current database user has INSERT permission on qt_pricelists +func (r *PricelistRepository) CanWrite() bool { + canWrite, _ := r.CanWriteDebug() + return canWrite +} + +// CanWriteDebug checks write permission and returns debug info +// Uses raw SQL with explicit columns to avoid schema mismatch issues +func (r *PricelistRepository) CanWriteDebug() (bool, string) { + // Check if table exists first + var count int64 + if err := r.db.Table("qt_pricelists").Count(&count).Error; err != nil { + return false, fmt.Sprintf("table check failed: %v", err) + } + + // Use raw SQL with only essential columns that always exist + // This avoids GORM model validation and schema mismatch issues + tx := r.db.Begin() + if tx.Error != nil { + return false, fmt.Sprintf("begin tx failed: %v", tx.Error) + } + defer tx.Rollback() // Always rollback - this is just a permission test + + testVersion := fmt.Sprintf("test-%06d", time.Now().Unix()%1000000) + + // Raw SQL insert with only core columns + err := tx.Exec(` + INSERT INTO qt_pricelists (version, created_by, is_active) + VALUES (?, 'system', 1) + `, testVersion).Error + + if err != nil { + // Check if it's a permission error vs other errors + errStr := err.Error() + if strings.Contains(errStr, "INSERT command denied") || + strings.Contains(errStr, "Access denied") { + return false, "no write permission" + } + return false, fmt.Sprintf("insert failed: %v", err) + } + + return true, "ok" +} + +// IncrementUsageCount increments the usage count for a pricelist +func (r *PricelistRepository) IncrementUsageCount(id uint) error { + return r.db.Model(&models.Pricelist{}).Where("id = ?", id). + UpdateColumn("usage_count", gorm.Expr("usage_count + 1")).Error +} + +// DecrementUsageCount decrements the usage count for a pricelist +func (r *PricelistRepository) DecrementUsageCount(id uint) error { + return r.db.Model(&models.Pricelist{}).Where("id = ?", id). + UpdateColumn("usage_count", gorm.Expr("GREATEST(usage_count - 1, 0)")).Error +} + +// GetExpiredUnused returns pricelists that are expired and unused +func (r *PricelistRepository) GetExpiredUnused() ([]models.Pricelist, error) { + var pricelists []models.Pricelist + if err := r.db.Where("expires_at < ? AND usage_count = 0", time.Now()). + Find(&pricelists).Error; err != nil { + return nil, fmt.Errorf("getting expired pricelists: %w", err) + } + return pricelists, nil +} diff --git a/internal/repository/unified.go b/internal/repository/unified.go new file mode 100644 index 0000000..51636b4 --- /dev/null +++ b/internal/repository/unified.go @@ -0,0 +1,399 @@ +package repository + +import ( + "encoding/json" + "fmt" + "time" + + "git.mchus.pro/mchus/quoteforge/internal/localdb" + "git.mchus.pro/mchus/quoteforge/internal/models" + "gorm.io/gorm" +) + +// DataSource defines the unified interface for data access +// It abstracts whether data comes from MariaDB (online) or SQLite (offline) +type DataSource interface { + // Components + GetComponents(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) + GetComponent(lotName string) (*models.LotMetadata, error) + + // Configurations + SaveConfiguration(cfg *models.Configuration) error + GetConfigurations(userID uint) ([]models.Configuration, error) + GetConfigurationByUUID(uuid string) (*models.Configuration, error) + DeleteConfiguration(uuid string) error + + // Pricelists (read-only in offline mode) + GetPricelists() ([]models.PricelistSummary, error) + GetPricelistByID(id uint) (*models.Pricelist, error) + GetPricelistItems(pricelistID uint) ([]models.PricelistItem, error) + GetLatestPricelist() (*models.Pricelist, error) +} + +// UnifiedRepo implements DataSource with automatic online/offline switching +type UnifiedRepo struct { + mariaDB *gorm.DB + localDB *localdb.LocalDB + isOnline bool +} + +// NewUnifiedRepo creates a new unified repository +func NewUnifiedRepo(mariaDB *gorm.DB, localDB *localdb.LocalDB, isOnline bool) *UnifiedRepo { + return &UnifiedRepo{ + mariaDB: mariaDB, + localDB: localDB, + isOnline: isOnline, + } +} + +// SetOnlineStatus updates the online/offline status +func (r *UnifiedRepo) SetOnlineStatus(online bool) { + r.isOnline = online +} + +// IsOnline returns the current online/offline status +func (r *UnifiedRepo) IsOnline() bool { + return r.isOnline +} + +// Component methods + +// GetComponents returns components from MariaDB (online) or local cache (offline) +func (r *UnifiedRepo) GetComponents(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) { + if r.isOnline { + return r.getComponentsOnline(filter, offset, limit) + } + return r.getComponentsOffline(filter, offset, limit) +} + +func (r *UnifiedRepo) getComponentsOnline(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) { + repo := NewComponentRepository(r.mariaDB) + return repo.List(filter, offset, limit) +} + +func (r *UnifiedRepo) getComponentsOffline(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) { + var components []localdb.LocalComponent + query := r.localDB.DB().Model(&localdb.LocalComponent{}) + + // Apply filters + if filter.Category != "" { + query = query.Where("category = ?", filter.Category) + } + if filter.Search != "" { + search := "%" + filter.Search + "%" + query = query.Where("lot_name LIKE ? OR lot_description LIKE ? OR model LIKE ?", search, search, search) + } + if filter.HasPrice { + query = query.Where("current_price IS NOT NULL AND current_price > 0") + } + + var total int64 + query.Count(&total) + + // Apply sorting + sortDir := "ASC" + if filter.SortDir == "desc" { + sortDir = "DESC" + } + switch filter.SortField { + case "current_price": + query = query.Order("current_price " + sortDir) + case "lot_name": + query = query.Order("lot_name " + sortDir) + default: + query = query.Order("lot_name ASC") + } + + if err := query.Offset(offset).Limit(limit).Find(&components).Error; err != nil { + return nil, 0, fmt.Errorf("fetching offline components: %w", err) + } + + // Convert to models.LotMetadata + result := make([]models.LotMetadata, len(components)) + for i, comp := range components { + result[i] = models.LotMetadata{ + LotName: comp.LotName, + Model: comp.Model, + CurrentPrice: comp.CurrentPrice, + Lot: &models.Lot{ + LotName: comp.LotName, + LotDescription: comp.LotDescription, + }, + } + } + + return result, total, nil +} + +// GetComponent returns a single component by lot name +func (r *UnifiedRepo) GetComponent(lotName string) (*models.LotMetadata, error) { + if r.isOnline { + repo := NewComponentRepository(r.mariaDB) + return repo.GetByLotName(lotName) + } + + var comp localdb.LocalComponent + if err := r.localDB.DB().Where("lot_name = ?", lotName).First(&comp).Error; err != nil { + return nil, fmt.Errorf("fetching offline component: %w", err) + } + + return &models.LotMetadata{ + LotName: comp.LotName, + Model: comp.Model, + CurrentPrice: comp.CurrentPrice, + Lot: &models.Lot{ + LotName: comp.LotName, + LotDescription: comp.LotDescription, + }, + }, nil +} + +// Configuration methods + +// SaveConfiguration saves a configuration (online: MariaDB, offline: SQLite + pending_changes) +func (r *UnifiedRepo) SaveConfiguration(cfg *models.Configuration) error { + if r.isOnline { + repo := NewConfigurationRepository(r.mariaDB) + return repo.Create(cfg) + } + + // Offline: save to local SQLite and queue for sync + localCfg := &localdb.LocalConfiguration{ + UUID: cfg.UUID, + Name: cfg.Name, + TotalPrice: cfg.TotalPrice, + CustomPrice: cfg.CustomPrice, + Notes: cfg.Notes, + IsTemplate: cfg.IsTemplate, + ServerCount: cfg.ServerCount, + CreatedAt: cfg.CreatedAt, + UpdatedAt: time.Now(), + SyncStatus: "pending", + } + + // Convert items + localItems := make(localdb.LocalConfigItems, len(cfg.Items)) + for i, item := range cfg.Items { + localItems[i] = localdb.LocalConfigItem{ + LotName: item.LotName, + Quantity: item.Quantity, + UnitPrice: item.UnitPrice, + } + } + localCfg.Items = localItems + + if err := r.localDB.SaveConfiguration(localCfg); err != nil { + return fmt.Errorf("saving local configuration: %w", err) + } + + // Add to pending changes queue + payload, err := json.Marshal(cfg) + if err != nil { + return fmt.Errorf("marshaling configuration for sync: %w", err) + } + + return r.localDB.AddPendingChange("configuration", cfg.UUID, "create", string(payload)) +} + +// GetConfigurations returns all configurations for a user +func (r *UnifiedRepo) GetConfigurations(userID uint) ([]models.Configuration, error) { + if r.isOnline { + repo := NewConfigurationRepository(r.mariaDB) + configs, _, err := repo.ListByUser(userID, 0, 1000) + return configs, err + } + + // Offline: get from local SQLite + localConfigs, err := r.localDB.GetConfigurations() + if err != nil { + return nil, fmt.Errorf("fetching local configurations: %w", err) + } + + // Convert to models.Configuration + result := make([]models.Configuration, len(localConfigs)) + for i, lc := range localConfigs { + items := make(models.ConfigItems, len(lc.Items)) + for j, item := range lc.Items { + items[j] = models.ConfigItem{ + LotName: item.LotName, + Quantity: item.Quantity, + UnitPrice: item.UnitPrice, + } + } + + result[i] = models.Configuration{ + UUID: lc.UUID, + Name: lc.Name, + Items: items, + TotalPrice: lc.TotalPrice, + CustomPrice: lc.CustomPrice, + Notes: lc.Notes, + IsTemplate: lc.IsTemplate, + ServerCount: lc.ServerCount, + CreatedAt: lc.CreatedAt, + } + } + + return result, nil +} + +// GetConfigurationByUUID returns a configuration by UUID +func (r *UnifiedRepo) GetConfigurationByUUID(uuid string) (*models.Configuration, error) { + if r.isOnline { + repo := NewConfigurationRepository(r.mariaDB) + return repo.GetByUUID(uuid) + } + + localCfg, err := r.localDB.GetConfigurationByUUID(uuid) + if err != nil { + return nil, fmt.Errorf("fetching local configuration: %w", err) + } + + items := make(models.ConfigItems, len(localCfg.Items)) + for i, item := range localCfg.Items { + items[i] = models.ConfigItem{ + LotName: item.LotName, + Quantity: item.Quantity, + UnitPrice: item.UnitPrice, + } + } + + return &models.Configuration{ + UUID: localCfg.UUID, + Name: localCfg.Name, + Items: items, + TotalPrice: localCfg.TotalPrice, + CustomPrice: localCfg.CustomPrice, + Notes: localCfg.Notes, + IsTemplate: localCfg.IsTemplate, + ServerCount: localCfg.ServerCount, + CreatedAt: localCfg.CreatedAt, + }, nil +} + +// DeleteConfiguration deletes a configuration +func (r *UnifiedRepo) DeleteConfiguration(uuid string) error { + if r.isOnline { + // Get ID first + cfg, err := r.GetConfigurationByUUID(uuid) + if err != nil { + return err + } + repo := NewConfigurationRepository(r.mariaDB) + return repo.Delete(cfg.ID) + } + + // Offline: delete from local and queue sync + if err := r.localDB.DeleteConfiguration(uuid); err != nil { + return fmt.Errorf("deleting local configuration: %w", err) + } + + return r.localDB.AddPendingChange("configuration", uuid, "delete", "") +} + +// Pricelist methods + +// GetPricelists returns all pricelists +func (r *UnifiedRepo) GetPricelists() ([]models.PricelistSummary, error) { + if r.isOnline { + repo := NewPricelistRepository(r.mariaDB) + summaries, _, err := repo.List(0, 1000) + return summaries, err + } + + // Offline: get from local cache + localPLs, err := r.localDB.GetLocalPricelists() + if err != nil { + return nil, fmt.Errorf("fetching local pricelists: %w", err) + } + + summaries := make([]models.PricelistSummary, len(localPLs)) + for i, pl := range localPLs { + itemCount := r.localDB.CountLocalPricelistItems(pl.ID) + summaries[i] = models.PricelistSummary{ + ID: pl.ServerID, + Version: pl.Version, + CreatedAt: pl.CreatedAt, + ItemCount: itemCount, + } + } + + return summaries, nil +} + +// GetPricelistByID returns a pricelist by ID +func (r *UnifiedRepo) GetPricelistByID(id uint) (*models.Pricelist, error) { + if r.isOnline { + repo := NewPricelistRepository(r.mariaDB) + return repo.GetByID(id) + } + + // Offline: get from local cache + localPL, err := r.localDB.GetLocalPricelistByServerID(id) + if err != nil { + return nil, fmt.Errorf("fetching local pricelist: %w", err) + } + + itemCount := r.localDB.CountLocalPricelistItems(localPL.ID) + return &models.Pricelist{ + ID: localPL.ServerID, + Version: localPL.Version, + CreatedAt: localPL.CreatedAt, + ItemCount: int(itemCount), + }, nil +} + +// GetPricelistItems returns items for a pricelist +func (r *UnifiedRepo) GetPricelistItems(pricelistID uint) ([]models.PricelistItem, error) { + if r.isOnline { + repo := NewPricelistRepository(r.mariaDB) + items, _, err := repo.GetItems(pricelistID, 0, 100000, "") + return items, err + } + + // Offline: get from local cache + // First find the local pricelist by server ID + localPL, err := r.localDB.GetLocalPricelistByServerID(pricelistID) + if err != nil { + return nil, fmt.Errorf("fetching local pricelist: %w", err) + } + + localItems, err := r.localDB.GetLocalPricelistItems(localPL.ID) + if err != nil { + return nil, fmt.Errorf("fetching local pricelist items: %w", err) + } + + items := make([]models.PricelistItem, len(localItems)) + for i, item := range localItems { + items[i] = models.PricelistItem{ + ID: item.ID, + PricelistID: pricelistID, + LotName: item.LotName, + Price: item.Price, + } + } + + return items, nil +} + +// GetLatestPricelist returns the latest pricelist +func (r *UnifiedRepo) GetLatestPricelist() (*models.Pricelist, error) { + if r.isOnline { + repo := NewPricelistRepository(r.mariaDB) + return repo.GetLatestActive() + } + + // Offline: get from local cache + localPL, err := r.localDB.GetLatestLocalPricelist() + if err != nil { + return nil, fmt.Errorf("fetching latest local pricelist: %w", err) + } + + itemCount := r.localDB.CountLocalPricelistItems(localPL.ID) + return &models.Pricelist{ + ID: localPL.ServerID, + Version: localPL.Version, + CreatedAt: localPL.CreatedAt, + ItemCount: int(itemCount), + }, nil +} diff --git a/internal/services/component.go b/internal/services/component.go index b39d8a3..2208a08 100644 --- a/internal/services/component.go +++ b/internal/services/component.go @@ -1,6 +1,7 @@ package services import ( + "fmt" "strings" "git.mchus.pro/mchus/quoteforge/internal/models" @@ -59,6 +60,17 @@ type ComponentView struct { } func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage int) (*ComponentListResult, error) { + // If no database connection (offline mode), return empty list + // Components should be loaded via /api/sync/components first + if s.componentRepo == nil { + return &ComponentListResult{ + Components: []ComponentView{}, + Total: 0, + Page: page, + PerPage: perPage, + }, nil + } + if page < 1 { page = 1 } @@ -106,6 +118,11 @@ func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage } func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error) { + // If no database connection (offline mode), return error + if s.componentRepo == nil { + return nil, fmt.Errorf("offline mode: component data not available") + } + c, err := s.componentRepo.GetByLotName(lotName) if err != nil { return nil, err @@ -135,11 +152,20 @@ func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error) } func (s *ComponentService) GetCategories() ([]models.Category, error) { + // If no database connection (offline mode), return default categories + if s.categoryRepo == nil { + return models.DefaultCategories, nil + } return s.categoryRepo.GetAll() } // ImportFromLot creates metadata entries for lots that don't have them func (s *ComponentService) ImportFromLot() (int, error) { + // If no database connection (offline mode), return error + if s.componentRepo == nil || s.categoryRepo == nil { + return 0, fmt.Errorf("offline mode: import not available") + } + lots, err := s.componentRepo.GetLotsWithoutMetadata() if err != nil { return 0, err diff --git a/internal/services/configuration.go b/internal/services/configuration.go index 764073c..97494ca 100644 --- a/internal/services/configuration.go +++ b/internal/services/configuration.go @@ -14,6 +14,12 @@ var ( ErrConfigForbidden = errors.New("access to configuration forbidden") ) +// ConfigurationGetter is an interface for services that can retrieve configurations +// Used by handlers to work with both ConfigurationService and LocalConfigurationService +type ConfigurationGetter interface { + GetByUUID(uuid string, userID uint) (*models.Configuration, error) +} + type ConfigurationService struct { configRepo *repository.ConfigurationRepository componentRepo *repository.ComponentRepository @@ -194,6 +200,149 @@ func (s *ConfigurationService) ListByUser(userID uint, page, perPage int) ([]mod return s.configRepo.ListByUser(userID, offset, perPage) } +// ListAll returns all configurations without user filter (for use when auth is disabled) +func (s *ConfigurationService) ListAll(page, perPage int) ([]models.Configuration, int64, error) { + if page < 1 { + page = 1 + } + if perPage < 1 || perPage > 100 { + perPage = 20 + } + offset := (page - 1) * perPage + + return s.configRepo.ListAll(offset, perPage) +} + +// GetByUUIDNoAuth returns configuration without ownership check (for use when auth is disabled) +func (s *ConfigurationService) GetByUUIDNoAuth(uuid string) (*models.Configuration, error) { + config, err := s.configRepo.GetByUUID(uuid) + if err != nil { + return nil, ErrConfigNotFound + } + return config, nil +} + +// UpdateNoAuth updates configuration without ownership check +func (s *ConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigRequest) (*models.Configuration, error) { + config, err := s.configRepo.GetByUUID(uuid) + if err != nil { + return nil, ErrConfigNotFound + } + + total := req.Items.Total() + if req.ServerCount > 1 { + total *= float64(req.ServerCount) + } + + config.Name = req.Name + config.Items = req.Items + config.TotalPrice = &total + config.CustomPrice = req.CustomPrice + config.Notes = req.Notes + config.IsTemplate = req.IsTemplate + config.ServerCount = req.ServerCount + + if err := s.configRepo.Update(config); err != nil { + return nil, err + } + + return config, nil +} + +// DeleteNoAuth deletes configuration without ownership check +func (s *ConfigurationService) DeleteNoAuth(uuid string) error { + config, err := s.configRepo.GetByUUID(uuid) + if err != nil { + return ErrConfigNotFound + } + return s.configRepo.Delete(config.ID) +} + +// RenameNoAuth renames configuration without ownership check +func (s *ConfigurationService) RenameNoAuth(uuid string, newName string) (*models.Configuration, error) { + config, err := s.configRepo.GetByUUID(uuid) + if err != nil { + return nil, ErrConfigNotFound + } + + config.Name = newName + if err := s.configRepo.Update(config); err != nil { + return nil, err + } + + return config, nil +} + +// CloneNoAuth clones configuration without ownership check +func (s *ConfigurationService) CloneNoAuth(configUUID string, newName string, userID uint) (*models.Configuration, error) { + original, err := s.configRepo.GetByUUID(configUUID) + if err != nil { + return nil, ErrConfigNotFound + } + + total := original.Items.Total() + if original.ServerCount > 1 { + total *= float64(original.ServerCount) + } + + clone := &models.Configuration{ + UUID: uuid.New().String(), + UserID: userID, // Use provided user ID + Name: newName, + Items: original.Items, + TotalPrice: &total, + CustomPrice: original.CustomPrice, + Notes: original.Notes, + IsTemplate: false, + ServerCount: original.ServerCount, + } + + if err := s.configRepo.Create(clone); err != nil { + return nil, err + } + + return clone, nil +} + +// RefreshPricesNoAuth refreshes prices without ownership check +func (s *ConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) { + config, err := s.configRepo.GetByUUID(uuid) + if err != nil { + return nil, ErrConfigNotFound + } + + updatedItems := make(models.ConfigItems, len(config.Items)) + for i, item := range config.Items { + metadata, err := s.componentRepo.GetByLotName(item.LotName) + if err != nil || metadata.CurrentPrice == nil { + updatedItems[i] = item + continue + } + + updatedItems[i] = models.ConfigItem{ + LotName: item.LotName, + Quantity: item.Quantity, + UnitPrice: *metadata.CurrentPrice, + } + } + + config.Items = updatedItems + total := updatedItems.Total() + if config.ServerCount > 1 { + total *= float64(config.ServerCount) + } + + config.TotalPrice = &total + now := time.Now() + config.PriceUpdatedAt = &now + + if err := s.configRepo.Update(config); err != nil { + return nil, err + } + + return config, nil +} + func (s *ConfigurationService) ListTemplates(page, perPage int) ([]models.Configuration, int64, error) { if page < 1 { page = 1 diff --git a/internal/services/local_configuration.go b/internal/services/local_configuration.go new file mode 100644 index 0000000..3b281dc --- /dev/null +++ b/internal/services/local_configuration.go @@ -0,0 +1,623 @@ +package services + +import ( + "encoding/json" + "time" + + "github.com/google/uuid" + "git.mchus.pro/mchus/quoteforge/internal/localdb" + "git.mchus.pro/mchus/quoteforge/internal/models" + "git.mchus.pro/mchus/quoteforge/internal/services/sync" +) + +// LocalConfigurationService handles configurations in local-first mode +// All operations go through SQLite, MariaDB is used only for sync +type LocalConfigurationService struct { + localDB *localdb.LocalDB + syncService *sync.Service + quoteService *QuoteService + isOnline func() bool // Function to check if we're online +} + +// NewLocalConfigurationService creates a new local-first configuration service +func NewLocalConfigurationService( + localDB *localdb.LocalDB, + syncService *sync.Service, + quoteService *QuoteService, + isOnline func() bool, +) *LocalConfigurationService { + return &LocalConfigurationService{ + localDB: localDB, + syncService: syncService, + quoteService: quoteService, + isOnline: isOnline, + } +} + +// Create creates a new configuration in local SQLite and queues it for sync +func (s *LocalConfigurationService) Create(userID uint, req *CreateConfigRequest) (*models.Configuration, error) { + // If online, check for new pricelists first + if s.isOnline() { + if err := s.syncService.SyncPricelistsIfNeeded(); err != nil { + // Log but don't fail - we can still use local pricelists + } + } + + total := req.Items.Total() + if req.ServerCount > 1 { + total *= float64(req.ServerCount) + } + + cfg := &models.Configuration{ + UUID: uuid.New().String(), + UserID: userID, + Name: req.Name, + Items: req.Items, + TotalPrice: &total, + CustomPrice: req.CustomPrice, + Notes: req.Notes, + IsTemplate: req.IsTemplate, + ServerCount: req.ServerCount, + CreatedAt: time.Now(), + } + + // Convert to local model + localCfg := localdb.ConfigurationToLocal(cfg) + + // Save to local SQLite + if err := s.localDB.SaveConfiguration(localCfg); err != nil { + return nil, err + } + + // Add to pending sync queue + payload, err := json.Marshal(cfg) + if err != nil { + return nil, err + } + if err := s.localDB.AddPendingChange("configuration", cfg.UUID, "create", string(payload)); err != nil { + return nil, err + } + + // Record usage stats + _ = s.quoteService.RecordUsage(req.Items) + + return cfg, nil +} + +// GetByUUID returns a configuration from local SQLite +func (s *LocalConfigurationService) GetByUUID(uuid string, userID uint) (*models.Configuration, error) { + localCfg, err := s.localDB.GetConfigurationByUUID(uuid) + if err != nil { + return nil, ErrConfigNotFound + } + + // Convert to models.Configuration + cfg := localdb.LocalToConfiguration(localCfg) + + // Allow access if user owns config or it's a template + if cfg.UserID != userID && !cfg.IsTemplate { + return nil, ErrConfigForbidden + } + + return cfg, nil +} + +// Update updates a configuration in local SQLite and queues it for sync +func (s *LocalConfigurationService) Update(uuid string, userID uint, req *CreateConfigRequest) (*models.Configuration, error) { + localCfg, err := s.localDB.GetConfigurationByUUID(uuid) + if err != nil { + return nil, ErrConfigNotFound + } + + if localCfg.OriginalUserID != userID { + return nil, ErrConfigForbidden + } + + total := req.Items.Total() + if req.ServerCount > 1 { + total *= float64(req.ServerCount) + } + + // Update fields + localCfg.Name = req.Name + localCfg.Items = localdb.LocalConfigItems{} + for _, item := range req.Items { + localCfg.Items = append(localCfg.Items, localdb.LocalConfigItem{ + LotName: item.LotName, + Quantity: item.Quantity, + UnitPrice: item.UnitPrice, + }) + } + localCfg.TotalPrice = &total + localCfg.CustomPrice = req.CustomPrice + localCfg.Notes = req.Notes + localCfg.IsTemplate = req.IsTemplate + localCfg.ServerCount = req.ServerCount + localCfg.UpdatedAt = time.Now() + localCfg.SyncStatus = "pending" + + // Save to local SQLite + if err := s.localDB.SaveConfiguration(localCfg); err != nil { + return nil, err + } + + // Add to pending sync queue + cfg := localdb.LocalToConfiguration(localCfg) + payload, err := json.Marshal(cfg) + if err != nil { + return nil, err + } + if err := s.localDB.AddPendingChange("configuration", uuid, "update", string(payload)); err != nil { + return nil, err + } + + return cfg, nil +} + +// Delete deletes a configuration from local SQLite and queues it for sync +func (s *LocalConfigurationService) Delete(uuid string, userID uint) error { + localCfg, err := s.localDB.GetConfigurationByUUID(uuid) + if err != nil { + return ErrConfigNotFound + } + + if localCfg.OriginalUserID != userID { + return ErrConfigForbidden + } + + // Delete from local SQLite + if err := s.localDB.DeleteConfiguration(uuid); err != nil { + return err + } + + // Add to pending sync queue + if err := s.localDB.AddPendingChange("configuration", uuid, "delete", ""); err != nil { + return err + } + + return nil +} + +// Rename renames a configuration +func (s *LocalConfigurationService) Rename(uuid string, userID uint, newName string) (*models.Configuration, error) { + localCfg, err := s.localDB.GetConfigurationByUUID(uuid) + if err != nil { + return nil, ErrConfigNotFound + } + + if localCfg.OriginalUserID != userID { + return nil, ErrConfigForbidden + } + + localCfg.Name = newName + localCfg.UpdatedAt = time.Now() + localCfg.SyncStatus = "pending" + + if err := s.localDB.SaveConfiguration(localCfg); err != nil { + return nil, err + } + + // Add to pending sync queue + cfg := localdb.LocalToConfiguration(localCfg) + payload, err := json.Marshal(cfg) + if err != nil { + return nil, err + } + if err := s.localDB.AddPendingChange("configuration", uuid, "update", string(payload)); err != nil { + return nil, err + } + + return cfg, nil +} + +// Clone clones a configuration +func (s *LocalConfigurationService) Clone(configUUID string, userID uint, newName string) (*models.Configuration, error) { + original, err := s.GetByUUID(configUUID, userID) + if err != nil { + return nil, err + } + + total := original.Items.Total() + if original.ServerCount > 1 { + total *= float64(original.ServerCount) + } + + clone := &models.Configuration{ + UUID: uuid.New().String(), + UserID: userID, + Name: newName, + Items: original.Items, + TotalPrice: &total, + CustomPrice: original.CustomPrice, + Notes: original.Notes, + IsTemplate: false, + ServerCount: original.ServerCount, + CreatedAt: time.Now(), + } + + localCfg := localdb.ConfigurationToLocal(clone) + if err := s.localDB.SaveConfiguration(localCfg); err != nil { + return nil, err + } + + // Add to pending sync queue + payload, err := json.Marshal(clone) + if err != nil { + return nil, err + } + if err := s.localDB.AddPendingChange("configuration", clone.UUID, "create", string(payload)); err != nil { + return nil, err + } + + return clone, nil +} + +// ListByUser returns all configurations for a user from local SQLite +func (s *LocalConfigurationService) ListByUser(userID uint, page, perPage int) ([]models.Configuration, int64, error) { + // Get all local configurations + localConfigs, err := s.localDB.GetConfigurations() + if err != nil { + return nil, 0, err + } + + // Filter by user + var userConfigs []models.Configuration + for _, lc := range localConfigs { + if lc.OriginalUserID == userID || lc.IsTemplate { + userConfigs = append(userConfigs, *localdb.LocalToConfiguration(&lc)) + } + } + + total := int64(len(userConfigs)) + + // Apply pagination + if page < 1 { + page = 1 + } + if perPage < 1 || perPage > 100 { + perPage = 20 + } + offset := (page - 1) * perPage + + start := offset + if start > len(userConfigs) { + start = len(userConfigs) + } + end := start + perPage + if end > len(userConfigs) { + end = len(userConfigs) + } + + return userConfigs[start:end], total, nil +} + +// RefreshPrices updates all component prices in the configuration from local cache +func (s *LocalConfigurationService) RefreshPrices(uuid string, userID uint) (*models.Configuration, error) { + // Get configuration from local SQLite + localCfg, err := s.localDB.GetConfigurationByUUID(uuid) + if err != nil { + return nil, ErrConfigNotFound + } + + // Check ownership + if localCfg.OriginalUserID != userID { + return nil, ErrConfigForbidden + } + + // Update prices for all items + updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items)) + for i, item := range localCfg.Items { + // Get current component price from local cache + component, err := s.localDB.GetLocalComponent(item.LotName) + if err != nil || component.CurrentPrice == nil { + // Keep original item if component not found or no price available + updatedItems[i] = item + continue + } + + // Update item with current price from local cache + updatedItems[i] = localdb.LocalConfigItem{ + LotName: item.LotName, + Quantity: item.Quantity, + UnitPrice: *component.CurrentPrice, + } + } + + // Update configuration + localCfg.Items = updatedItems + total := updatedItems.Total() + + // If server count is greater than 1, multiply the total by server count + if localCfg.ServerCount > 1 { + total *= float64(localCfg.ServerCount) + } + + localCfg.TotalPrice = &total + + // Set price update timestamp and mark for sync + now := time.Now() + localCfg.PriceUpdatedAt = &now + localCfg.UpdatedAt = now + localCfg.SyncStatus = "pending" + + // Save to local SQLite + if err := s.localDB.SaveConfiguration(localCfg); err != nil { + return nil, err + } + + // Add to pending sync queue + cfg := localdb.LocalToConfiguration(localCfg) + payload, err := json.Marshal(cfg) + if err != nil { + return nil, err + } + if err := s.localDB.AddPendingChange("configuration", uuid, "update", string(payload)); err != nil { + return nil, err + } + + return cfg, nil +} + +// GetByUUIDNoAuth returns configuration without ownership check +func (s *LocalConfigurationService) GetByUUIDNoAuth(uuid string) (*models.Configuration, error) { + localCfg, err := s.localDB.GetConfigurationByUUID(uuid) + if err != nil { + return nil, ErrConfigNotFound + } + return localdb.LocalToConfiguration(localCfg), nil +} + +// UpdateNoAuth updates configuration without ownership check +func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigRequest) (*models.Configuration, error) { + localCfg, err := s.localDB.GetConfigurationByUUID(uuid) + if err != nil { + return nil, ErrConfigNotFound + } + + total := req.Items.Total() + if req.ServerCount > 1 { + total *= float64(req.ServerCount) + } + + localCfg.Name = req.Name + localCfg.Items = localdb.LocalConfigItems{} + for _, item := range req.Items { + localCfg.Items = append(localCfg.Items, localdb.LocalConfigItem{ + LotName: item.LotName, + Quantity: item.Quantity, + UnitPrice: item.UnitPrice, + }) + } + localCfg.TotalPrice = &total + localCfg.CustomPrice = req.CustomPrice + localCfg.Notes = req.Notes + localCfg.IsTemplate = req.IsTemplate + localCfg.ServerCount = req.ServerCount + localCfg.UpdatedAt = time.Now() + localCfg.SyncStatus = "pending" + + if err := s.localDB.SaveConfiguration(localCfg); err != nil { + return nil, err + } + + cfg := localdb.LocalToConfiguration(localCfg) + payload, err := json.Marshal(cfg) + if err != nil { + return nil, err + } + if err := s.localDB.AddPendingChange("configuration", uuid, "update", string(payload)); err != nil { + return nil, err + } + + return cfg, nil +} + +// DeleteNoAuth deletes configuration without ownership check +func (s *LocalConfigurationService) DeleteNoAuth(uuid string) error { + if err := s.localDB.DeleteConfiguration(uuid); err != nil { + return err + } + return s.localDB.AddPendingChange("configuration", uuid, "delete", "") +} + +// RenameNoAuth renames configuration without ownership check +func (s *LocalConfigurationService) RenameNoAuth(uuid string, newName string) (*models.Configuration, error) { + localCfg, err := s.localDB.GetConfigurationByUUID(uuid) + if err != nil { + return nil, ErrConfigNotFound + } + + localCfg.Name = newName + localCfg.UpdatedAt = time.Now() + localCfg.SyncStatus = "pending" + + if err := s.localDB.SaveConfiguration(localCfg); err != nil { + return nil, err + } + + cfg := localdb.LocalToConfiguration(localCfg) + payload, err := json.Marshal(cfg) + if err != nil { + return nil, err + } + if err := s.localDB.AddPendingChange("configuration", uuid, "update", string(payload)); err != nil { + return nil, err + } + + return cfg, nil +} + +// CloneNoAuth clones configuration without ownership check +func (s *LocalConfigurationService) CloneNoAuth(configUUID string, newName string, userID uint) (*models.Configuration, error) { + original, err := s.GetByUUIDNoAuth(configUUID) + if err != nil { + return nil, err + } + + total := original.Items.Total() + if original.ServerCount > 1 { + total *= float64(original.ServerCount) + } + + clone := &models.Configuration{ + UUID: uuid.New().String(), + UserID: userID, + Name: newName, + Items: original.Items, + TotalPrice: &total, + CustomPrice: original.CustomPrice, + Notes: original.Notes, + IsTemplate: false, + ServerCount: original.ServerCount, + CreatedAt: time.Now(), + } + + localCfg := localdb.ConfigurationToLocal(clone) + if err := s.localDB.SaveConfiguration(localCfg); err != nil { + return nil, err + } + + payload, err := json.Marshal(clone) + if err != nil { + return nil, err + } + if err := s.localDB.AddPendingChange("configuration", clone.UUID, "create", string(payload)); err != nil { + return nil, err + } + + return clone, nil +} + +// ListAll returns all configurations without user filter +func (s *LocalConfigurationService) ListAll(page, perPage int) ([]models.Configuration, int64, error) { + localConfigs, err := s.localDB.GetConfigurations() + if err != nil { + return nil, 0, err + } + + configs := make([]models.Configuration, len(localConfigs)) + for i, lc := range localConfigs { + configs[i] = *localdb.LocalToConfiguration(&lc) + } + + total := int64(len(configs)) + + // Apply pagination + if page < 1 { + page = 1 + } + if perPage < 1 || perPage > 100 { + perPage = 20 + } + offset := (page - 1) * perPage + + start := offset + if start > len(configs) { + start = len(configs) + } + end := start + perPage + if end > len(configs) { + end = len(configs) + } + + return configs[start:end], total, nil +} + +// ListTemplates returns all template configurations +func (s *LocalConfigurationService) ListTemplates(page, perPage int) ([]models.Configuration, int64, error) { + localConfigs, err := s.localDB.GetConfigurations() + if err != nil { + return nil, 0, err + } + + var templates []models.Configuration + for _, lc := range localConfigs { + if lc.IsTemplate { + templates = append(templates, *localdb.LocalToConfiguration(&lc)) + } + } + + total := int64(len(templates)) + + // Apply pagination + if page < 1 { + page = 1 + } + if perPage < 1 || perPage > 100 { + perPage = 20 + } + offset := (page - 1) * perPage + + start := offset + if start > len(templates) { + start = len(templates) + } + end := start + perPage + if end > len(templates) { + end = len(templates) + } + + return templates[start:end], total, nil +} + +// RefreshPricesNoAuth updates all component prices in the configuration without ownership check +func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) { + // Get configuration from local SQLite + localCfg, err := s.localDB.GetConfigurationByUUID(uuid) + if err != nil { + return nil, ErrConfigNotFound + } + + // Update prices for all items + updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items)) + for i, item := range localCfg.Items { + // Get current component price from local cache + component, err := s.localDB.GetLocalComponent(item.LotName) + if err != nil || component.CurrentPrice == nil { + // Keep original item if component not found or no price available + updatedItems[i] = item + continue + } + + // Update item with current price from local cache + updatedItems[i] = localdb.LocalConfigItem{ + LotName: item.LotName, + Quantity: item.Quantity, + UnitPrice: *component.CurrentPrice, + } + } + + // Update configuration + localCfg.Items = updatedItems + total := updatedItems.Total() + + // If server count is greater than 1, multiply the total by server count + if localCfg.ServerCount > 1 { + total *= float64(localCfg.ServerCount) + } + + localCfg.TotalPrice = &total + + // Set price update timestamp and mark for sync + now := time.Now() + localCfg.PriceUpdatedAt = &now + localCfg.UpdatedAt = now + localCfg.SyncStatus = "pending" + + // Save to local SQLite + if err := s.localDB.SaveConfiguration(localCfg); err != nil { + return nil, err + } + + // Add to pending sync queue + cfg := localdb.LocalToConfiguration(localCfg) + payload, err := json.Marshal(cfg) + if err != nil { + return nil, err + } + if err := s.localDB.AddPendingChange("configuration", uuid, "update", string(payload)); err != nil { + return nil, err + } + + return cfg, nil +} diff --git a/internal/services/pricelist/service.go b/internal/services/pricelist/service.go new file mode 100644 index 0000000..7af325b --- /dev/null +++ b/internal/services/pricelist/service.go @@ -0,0 +1,187 @@ +package pricelist + +import ( + "fmt" + "log/slog" + "time" + + "git.mchus.pro/mchus/quoteforge/internal/models" + "git.mchus.pro/mchus/quoteforge/internal/repository" + "gorm.io/gorm" +) + +type Service struct { + repo *repository.PricelistRepository + componentRepo *repository.ComponentRepository + db *gorm.DB +} + +func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo *repository.ComponentRepository) *Service { + return &Service{ + repo: repo, + componentRepo: componentRepo, + db: db, + } +} + +// CreateFromCurrentPrices creates a new pricelist by taking a snapshot of current prices +func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist, error) { + if s.repo == nil || s.db == nil { + return nil, fmt.Errorf("offline mode: cannot create pricelists") + } + + version, err := s.repo.GenerateVersion() + if err != nil { + return nil, fmt.Errorf("generating version: %w", err) + } + + expiresAt := time.Now().AddDate(1, 0, 0) // +1 year + + pricelist := &models.Pricelist{ + Version: version, + CreatedBy: createdBy, + IsActive: true, + ExpiresAt: &expiresAt, + } + + if err := s.repo.Create(pricelist); err != nil { + return nil, fmt.Errorf("creating pricelist: %w", err) + } + + // Get all components with prices from qt_lot_metadata + 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 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, + ) + + return pricelist, nil +} + +// List returns pricelists with pagination +func (s *Service) List(page, perPage int) ([]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.List(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) +} + +// 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) +} + +// 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) { + if s.repo == nil { + return nil, fmt.Errorf("offline mode: pricelist service not available") + } + return s.repo.GetLatestActive() +} + +// 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/sync/service.go b/internal/services/sync/service.go new file mode 100644 index 0000000..9c0bba7 --- /dev/null +++ b/internal/services/sync/service.go @@ -0,0 +1,483 @@ +package sync + +import ( + "encoding/json" + "fmt" + "log/slog" + "time" + + "git.mchus.pro/mchus/quoteforge/internal/db" + "git.mchus.pro/mchus/quoteforge/internal/localdb" + "git.mchus.pro/mchus/quoteforge/internal/models" + "git.mchus.pro/mchus/quoteforge/internal/repository" +) + +// Service handles synchronization between MariaDB and local SQLite +type Service struct { + connMgr *db.ConnectionManager + localDB *localdb.LocalDB +} + +// NewService creates a new sync service +func NewService(connMgr *db.ConnectionManager, localDB *localdb.LocalDB) *Service { + return &Service{ + connMgr: connMgr, + localDB: localDB, + } +} + +// SyncStatus represents the current sync status +type SyncStatus struct { + LastSyncAt *time.Time `json:"last_sync_at"` + ServerPricelists int `json:"server_pricelists"` + LocalPricelists int `json:"local_pricelists"` + NeedsSync bool `json:"needs_sync"` +} + +// GetStatus returns the current sync status +func (s *Service) GetStatus() (*SyncStatus, error) { + lastSync := s.localDB.GetLastSyncTime() + + // Count server pricelists (only if already connected, don't reconnect) + serverCount := 0 + connStatus := s.connMgr.GetStatus() + if connStatus.IsConnected { + if mariaDB, err := s.connMgr.GetDB(); err == nil && mariaDB != nil { + pricelistRepo := repository.NewPricelistRepository(mariaDB) + serverPricelists, _, err := pricelistRepo.List(0, 1) + if err == nil { + serverCount = len(serverPricelists) + } + } + } + + // Count local pricelists + localCount := s.localDB.CountLocalPricelists() + + needsSync, _ := s.NeedSync() + + return &SyncStatus{ + LastSyncAt: lastSync, + ServerPricelists: serverCount, + LocalPricelists: int(localCount), + NeedsSync: needsSync, + }, nil +} + +// NeedSync checks if synchronization is needed +// Returns true if there are new pricelists on server or last sync was >1 hour ago +func (s *Service) NeedSync() (bool, error) { + lastSync := s.localDB.GetLastSyncTime() + + // If never synced, need sync + if lastSync == nil { + return true, nil + } + + // If last sync was more than 1 hour ago, suggest sync + if time.Since(*lastSync) > time.Hour { + return true, nil + } + + // Check if there are new pricelists on server (only if already connected) + connStatus := s.connMgr.GetStatus() + if !connStatus.IsConnected { + // If offline, can't check server, no need to sync + return false, nil + } + + mariaDB, err := s.connMgr.GetDB() + if err != nil { + // If offline, can't check server, no need to sync + return false, nil + } + + pricelistRepo := repository.NewPricelistRepository(mariaDB) + latestServer, err := pricelistRepo.GetLatestActive() + if err != nil { + // If no pricelists on server, no need to sync + return false, nil + } + + latestLocal, err := s.localDB.GetLatestLocalPricelist() + if err != nil { + // No local pricelists, need to sync + return true, nil + } + + // If server has newer pricelist, need sync + if latestServer.ID != latestLocal.ServerID { + return true, nil + } + + return false, nil +} + +// SyncPricelists synchronizes all active pricelists from server to local SQLite +func (s *Service) SyncPricelists() (int, error) { + slog.Info("starting pricelist sync") + + // Get database connection + mariaDB, err := s.connMgr.GetDB() + if err != nil { + return 0, fmt.Errorf("database not available: %w", err) + } + + // Create repository + pricelistRepo := repository.NewPricelistRepository(mariaDB) + + // Get all active pricelists from server (up to 100) + serverPricelists, _, err := pricelistRepo.List(0, 100) + if err != nil { + return 0, fmt.Errorf("getting server pricelists: %w", err) + } + + synced := 0 + var latestLocalID uint + for _, pl := range serverPricelists { + // Check if pricelist already exists locally + existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID) + if existing != nil { + // Already synced, track latest + latestLocalID = existing.ID + continue + } + + // Create local pricelist + localPL := &localdb.LocalPricelist{ + ServerID: pl.ID, + Version: pl.Version, + Name: pl.Notification, // Using notification as name + CreatedAt: pl.CreatedAt, + SyncedAt: time.Now(), + IsUsed: false, + } + + if err := s.localDB.SaveLocalPricelist(localPL); err != nil { + slog.Warn("failed to save local pricelist", "version", pl.Version, "error", err) + continue + } + + // Sync items for the newly created pricelist + itemCount, err := s.SyncPricelistItems(localPL.ID) + if err != nil { + slog.Warn("failed to sync pricelist items", "version", pl.Version, "error", err) + // Continue even if items sync fails - we have the pricelist metadata + } else { + slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount) + } + + latestLocalID = localPL.ID + synced++ + } + + // Update component prices from latest pricelist + if latestLocalID > 0 { + updated, err := s.localDB.UpdateComponentPricesFromPricelist(latestLocalID) + if err != nil { + slog.Warn("failed to update component prices from pricelist", "error", err) + } else { + slog.Info("updated component prices from latest pricelist", "updated", updated) + } + } + + // Update last sync time + s.localDB.SetLastSyncTime(time.Now()) + + slog.Info("pricelist sync completed", "synced", synced, "total", len(serverPricelists)) + return synced, nil +} + +// SyncPricelistItems synchronizes items for a specific pricelist +func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) { + // Get local pricelist + localPL, err := s.localDB.GetLocalPricelistByID(localPricelistID) + if err != nil { + return 0, fmt.Errorf("getting local pricelist: %w", err) + } + + // Check if items already exist + existingCount := s.localDB.CountLocalPricelistItems(localPricelistID) + if existingCount > 0 { + slog.Debug("pricelist items already synced", "pricelist_id", localPricelistID, "count", existingCount) + return int(existingCount), nil + } + + // Get database connection + mariaDB, err := s.connMgr.GetDB() + if err != nil { + return 0, fmt.Errorf("database not available: %w", err) + } + + // Create repository + pricelistRepo := repository.NewPricelistRepository(mariaDB) + + // Get items from server + serverItems, _, err := pricelistRepo.GetItems(localPL.ServerID, 0, 10000, "") + if err != nil { + return 0, fmt.Errorf("getting server pricelist items: %w", err) + } + + // Convert and save locally + localItems := make([]localdb.LocalPricelistItem, len(serverItems)) + for i, item := range serverItems { + localItems[i] = localdb.LocalPricelistItem{ + PricelistID: localPricelistID, + LotName: item.LotName, + Price: item.Price, + } + } + + if err := s.localDB.SaveLocalPricelistItems(localItems); err != nil { + return 0, fmt.Errorf("saving local pricelist items: %w", err) + } + + slog.Info("synced pricelist items", "pricelist_id", localPricelistID, "items", len(localItems)) + return len(localItems), nil +} + +// SyncPricelistItemsByServerID syncs items for a pricelist by its server ID +func (s *Service) SyncPricelistItemsByServerID(serverPricelistID uint) (int, error) { + localPL, err := s.localDB.GetLocalPricelistByServerID(serverPricelistID) + if err != nil { + return 0, fmt.Errorf("local pricelist not found for server ID %d", serverPricelistID) + } + return s.SyncPricelistItems(localPL.ID) +} + +// GetLocalPriceForLot returns the price for a lot from a local pricelist +func (s *Service) GetLocalPriceForLot(localPricelistID uint, lotName string) (float64, error) { + return s.localDB.GetLocalPriceForLot(localPricelistID, lotName) +} + +// GetPricelistForOffline returns a pricelist suitable for offline use +// If items are not synced, it will sync them first +func (s *Service) GetPricelistForOffline(serverPricelistID uint) (*localdb.LocalPricelist, error) { + // Ensure pricelist is synced + localPL, err := s.localDB.GetLocalPricelistByServerID(serverPricelistID) + if err != nil { + // Try to sync pricelists first + if _, err := s.SyncPricelists(); err != nil { + return nil, fmt.Errorf("syncing pricelists: %w", err) + } + + // Try again + localPL, err = s.localDB.GetLocalPricelistByServerID(serverPricelistID) + if err != nil { + return nil, fmt.Errorf("pricelist not found on server: %w", err) + } + } + + // Ensure items are synced + if _, err := s.SyncPricelistItems(localPL.ID); err != nil { + return nil, fmt.Errorf("syncing pricelist items: %w", err) + } + + return localPL, nil +} + +// SyncPricelistsIfNeeded checks for new pricelists and syncs if needed +// This should be called before creating a new configuration when online +func (s *Service) SyncPricelistsIfNeeded() error { + needSync, err := s.NeedSync() + if err != nil { + slog.Warn("failed to check if sync needed", "error", err) + return nil // Don't fail on check error + } + + if !needSync { + slog.Debug("pricelists are up to date, no sync needed") + return nil + } + + slog.Info("new pricelists detected, syncing...") + _, err = s.SyncPricelists() + if err != nil { + return fmt.Errorf("syncing pricelists: %w", err) + } + + return nil +} + +// PushPendingChanges pushes all pending changes to the server +func (s *Service) PushPendingChanges() (int, error) { + changes, err := s.localDB.GetPendingChanges() + if err != nil { + return 0, fmt.Errorf("getting pending changes: %w", err) + } + + if len(changes) == 0 { + slog.Debug("no pending changes to push") + return 0, nil + } + + slog.Info("pushing pending changes", "count", len(changes)) + pushed := 0 + var syncedIDs []int64 + + for _, change := range changes { + err := s.pushSingleChange(&change) + if err != nil { + slog.Warn("failed to push change", "id", change.ID, "type", change.EntityType, "operation", change.Operation, "error", err) + // Increment attempts + s.localDB.IncrementPendingChangeAttempts(change.ID, err.Error()) + continue + } + + syncedIDs = append(syncedIDs, change.ID) + pushed++ + } + + // Mark synced changes as complete by deleting them + if len(syncedIDs) > 0 { + if err := s.localDB.MarkChangesSynced(syncedIDs); err != nil { + slog.Error("failed to mark changes as synced", "error", err) + } + } + + slog.Info("pending changes pushed", "pushed", pushed, "failed", len(changes)-pushed) + return pushed, nil +} + +// pushSingleChange pushes a single pending change to the server +func (s *Service) pushSingleChange(change *localdb.PendingChange) error { + switch change.EntityType { + case "configuration": + return s.pushConfigurationChange(change) + default: + return fmt.Errorf("unknown entity type: %s", change.EntityType) + } +} + +// pushConfigurationChange pushes a configuration change to the server +func (s *Service) pushConfigurationChange(change *localdb.PendingChange) error { + switch change.Operation { + case "create": + return s.pushConfigurationCreate(change) + case "update": + return s.pushConfigurationUpdate(change) + case "delete": + return s.pushConfigurationDelete(change) + default: + return fmt.Errorf("unknown operation: %s", change.Operation) + } +} + +// pushConfigurationCreate creates a configuration on the server +func (s *Service) pushConfigurationCreate(change *localdb.PendingChange) error { + var cfg models.Configuration + if err := json.Unmarshal([]byte(change.Payload), &cfg); err != nil { + return fmt.Errorf("unmarshaling configuration: %w", err) + } + + // Get database connection + mariaDB, err := s.connMgr.GetDB() + if err != nil { + return fmt.Errorf("database not available: %w", err) + } + + // Create repository + configRepo := repository.NewConfigurationRepository(mariaDB) + + // Create on server + if err := configRepo.Create(&cfg); err != nil { + return fmt.Errorf("creating configuration on server: %w", err) + } + + // Update local configuration with server ID + localCfg, err := s.localDB.GetConfigurationByUUID(cfg.UUID) + if err == nil { + serverID := cfg.ID + localCfg.ServerID = &serverID + localCfg.SyncStatus = "synced" + s.localDB.SaveConfiguration(localCfg) + } + + slog.Info("configuration created on server", "uuid", cfg.UUID, "server_id", cfg.ID) + return nil +} + +// pushConfigurationUpdate updates a configuration on the server +func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error { + var cfg models.Configuration + if err := json.Unmarshal([]byte(change.Payload), &cfg); err != nil { + return fmt.Errorf("unmarshaling configuration: %w", err) + } + + // Get database connection + mariaDB, err := s.connMgr.GetDB() + if err != nil { + return fmt.Errorf("database not available: %w", err) + } + + // Create repository + configRepo := repository.NewConfigurationRepository(mariaDB) + + // Ensure we have a server ID before updating + // If the payload doesn't have ID, get it from local configuration + if cfg.ID == 0 { + localCfg, err := s.localDB.GetConfigurationByUUID(cfg.UUID) + if err != nil { + return fmt.Errorf("getting local configuration: %w", err) + } + + if localCfg.ServerID == nil { + // Configuration hasn't been synced yet, try to find it on server by UUID + serverCfg, err := configRepo.GetByUUID(cfg.UUID) + if err != nil { + return fmt.Errorf("configuration not yet synced to server: %w", err) + } + cfg.ID = serverCfg.ID + + // Update local with server ID + serverID := serverCfg.ID + localCfg.ServerID = &serverID + s.localDB.SaveConfiguration(localCfg) + } else { + cfg.ID = *localCfg.ServerID + } + } + + // Update on server + if err := configRepo.Update(&cfg); err != nil { + return fmt.Errorf("updating configuration on server: %w", err) + } + + // Update local sync status + localCfg, err := s.localDB.GetConfigurationByUUID(cfg.UUID) + if err == nil { + localCfg.SyncStatus = "synced" + s.localDB.SaveConfiguration(localCfg) + } + + slog.Info("configuration updated on server", "uuid", cfg.UUID) + return nil +} + +// pushConfigurationDelete deletes a configuration from the server +func (s *Service) pushConfigurationDelete(change *localdb.PendingChange) error { + // Get database connection + mariaDB, err := s.connMgr.GetDB() + if err != nil { + return fmt.Errorf("database not available: %w", err) + } + + // Create repository + configRepo := repository.NewConfigurationRepository(mariaDB) + + // Get the configuration from server by UUID to get the ID + cfg, err := configRepo.GetByUUID(change.EntityUUID) + if err != nil { + // Already deleted or not found, consider it successful + slog.Warn("configuration not found on server, considering delete successful", "uuid", change.EntityUUID) + return nil + } + + // Delete from server + if err := configRepo.Delete(cfg.ID); err != nil { + return fmt.Errorf("deleting configuration from server: %w", err) + } + + slog.Info("configuration deleted from server", "uuid", change.EntityUUID) + return nil +} diff --git a/internal/services/sync/worker.go b/internal/services/sync/worker.go new file mode 100644 index 0000000..2895c44 --- /dev/null +++ b/internal/services/sync/worker.go @@ -0,0 +1,89 @@ +package sync + +import ( + "context" + "log/slog" + "time" + + "git.mchus.pro/mchus/quoteforge/internal/db" +) + +// Worker performs background synchronization at regular intervals +type Worker struct { + service *Service + connMgr *db.ConnectionManager + interval time.Duration + logger *slog.Logger + stopCh chan struct{} +} + +// NewWorker creates a new background sync worker +func NewWorker(service *Service, connMgr *db.ConnectionManager, interval time.Duration) *Worker { + return &Worker{ + service: service, + connMgr: connMgr, + interval: interval, + logger: slog.Default(), + stopCh: make(chan struct{}), + } +} + +// isOnline checks if the database connection is available +func (w *Worker) isOnline() bool { + return w.connMgr.IsOnline() +} + +// Start begins the background sync loop in a goroutine +func (w *Worker) Start(ctx context.Context) { + w.logger.Info("starting background sync worker", "interval", w.interval) + + ticker := time.NewTicker(w.interval) + defer ticker.Stop() + + // Run once immediately + w.runSync() + + for { + select { + case <-ctx.Done(): + w.logger.Info("background sync worker stopped by context") + return + case <-w.stopCh: + w.logger.Info("background sync worker stopped") + return + case <-ticker.C: + w.runSync() + } + } +} + +// Stop gracefully stops the worker +func (w *Worker) Stop() { + w.logger.Info("stopping background sync worker") + close(w.stopCh) +} + +// runSync performs a single sync iteration +func (w *Worker) runSync() { + // Check if online + if !w.isOnline() { + w.logger.Debug("offline, skipping background sync") + return + } + + // Push pending changes first + pushed, err := w.service.PushPendingChanges() + if err != nil { + w.logger.Warn("background sync: failed to push pending changes", "error", err) + } else if pushed > 0 { + w.logger.Info("background sync: pushed pending changes", "count", pushed) + } + + // Then check for new pricelists + err = w.service.SyncPricelistsIfNeeded() + if err != nil { + w.logger.Warn("background sync: failed to sync pricelists", "error", err) + } + + w.logger.Info("background sync cycle completed") +} diff --git a/web/templates/admin_pricing.html b/web/templates/admin_pricing.html index 5423453..d609d32 100644 --- a/web/templates/admin_pricing.html +++ b/web/templates/admin_pricing.html @@ -9,6 +9,7 @@