From 143d2173974244eb7f1cb4686b2a69ed65a3e79e Mon Sep 17 00:00:00 2001
From: Michael Chus
Date: Sun, 1 Feb 2026 11:00:32 +0300
Subject: [PATCH 01/31] Add Phase 2: Local SQLite database with sync
functionality
Implements complete offline-first architecture with SQLite caching and MariaDB synchronization.
Key features:
- Local SQLite database for offline operation (data/quoteforge.db)
- Connection settings with encrypted credentials
- Component and pricelist caching with auto-sync
- Sync API endpoints (/api/sync/status, /components, /pricelists, /all)
- Real-time sync status indicator in UI with auto-refresh
- Offline mode detection middleware
- Migration tool for database initialization
- Setup wizard for initial configuration
New components:
- internal/localdb: SQLite repository layer (components, pricelists, sync)
- internal/services/sync: Synchronization service
- internal/handlers/sync: Sync API handlers
- internal/handlers/setup: Setup wizard handlers
- internal/middleware/offline: Offline detection
- cmd/migrate: Database migration tool
UI improvements:
- Setup page for database configuration
- Sync status indicator with online/offline detection
- Warning icons for pending synchronization
- Auto-refresh every 30 seconds
Co-Authored-By: Claude Sonnet 4.5
---
.gitignore | 6 +
CLAUDE.md | 683 +++----------------------
cmd/cron/main.go | 1 -
cmd/migrate/main.go | 162 ++++++
cmd/server/main.go | 394 +++++++++++---
go.mod | 10 +-
go.sum | 26 +-
internal/handlers/pricelist.go | 134 +++++
internal/handlers/setup.go | 196 +++++++
internal/handlers/sync.go | 217 ++++++++
internal/handlers/web.go | 10 +-
internal/localdb/components.go | 268 ++++++++++
internal/localdb/encryption.go | 87 ++++
internal/localdb/localdb.go | 339 ++++++++++++
internal/localdb/models.go | 122 +++++
internal/middleware/offline.go | 43 ++
internal/models/models.go | 57 ++-
internal/models/pricelist.go | 58 +++
internal/repository/configuration.go | 15 +
internal/repository/pricelist.go | 259 ++++++++++
internal/services/configuration.go | 143 ++++++
internal/services/pricelist/service.go | 156 ++++++
internal/services/sync/service.go | 215 ++++++++
web/templates/admin_pricing.html | 55 +-
web/templates/base.html | 133 +++--
web/templates/configs.html | 102 +---
web/templates/index.html | 36 +-
web/templates/pricelist_detail.html | 270 ++++++++++
web/templates/pricelists.html | 234 +++++++++
web/templates/setup.html | 153 ++++++
30 files changed, 3697 insertions(+), 887 deletions(-)
create mode 100644 cmd/migrate/main.go
create mode 100644 internal/handlers/pricelist.go
create mode 100644 internal/handlers/setup.go
create mode 100644 internal/handlers/sync.go
create mode 100644 internal/localdb/components.go
create mode 100644 internal/localdb/encryption.go
create mode 100644 internal/localdb/localdb.go
create mode 100644 internal/localdb/models.go
create mode 100644 internal/middleware/offline.go
create mode 100644 internal/models/pricelist.go
create mode 100644 internal/repository/pricelist.go
create mode 100644 internal/services/pricelist/service.go
create mode 100644 internal/services/sync/service.go
create mode 100644 web/templates/pricelist_detail.html
create mode 100644 web/templates/pricelists.html
create mode 100644 web/templates/setup.html
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..2702afd 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,638 +1,107 @@
# 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
+Приложение должно полностью работать без MariaDB, синхронизация при восстановлении связи.
-### Phase 3: Local SQLite Database
-- Локальное хранение настроек подключения
-- Кэширование прайслистов
-- Локальные проекты и спецификации
-- Синхронизация с сервером
+**Architecture:**
+- Dual-source pattern: все операции идут через unified service layer
+- Online: read/write MariaDB, async cache to SQLite
+- Offline: read/write SQLite, queue changes for sync
+
+**TODO:**
+- ❌ Unified repository interface (online/offline transparent switching)
+- ❌ Sync queue table (pending_changes: entity_type, entity_uuid, operation, payload, created_at)
+- ❌ Background sync worker (push local changes when online)
+- ❌ Conflict resolution (last-write-wins by updated_at, or manual)
+- ❌ Initial data bootstrap (first sync downloads all needed data)
+- ❌ Handlers use context.IsOffline to choose data source
+- ❌ UI: pending changes counter, manual sync button, conflict alerts
+
+**Sync flow:**
+1. Online → Offline: continue work, changes saved locally with sync_status='pending'
+2. Offline → Online: background worker pushes pending_changes, pulls updates
+3. Conflict: if server version newer, mark as 'conflict' for manual resolution
+
+### 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
+- `local_pricelists/items` - cached from server
+- `local_components` - lot cache for offline search
+- `local_configurations` - with sync_status (pending/synced/conflict)
+- `local_projects/specifications` - Phase 3
+- `pending_changes` - sync queue (entity_type, uuid, op, payload, created_at)
-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) |
+| 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/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..644ab11 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -3,53 +3,97 @@ 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/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)
+ // Get DSN from local SQLite
+ dsn, err := local.GetDSN()
+ if err != nil {
+ slog.Error("failed to get database settings", "error", err)
+ os.Exit(1)
+ }
+
+ // Connect to MariaDB
+ db, err := setupDatabaseFromDSN(dsn)
+ if err != nil {
+ slog.Error("failed to connect to database", "error", err)
+ slog.Info("you may need to reconfigure connection at /setup")
+ os.Exit(1)
+ }
+
+ dbUser := local.GetDBUser()
+
+ // Ensure DB user exists in qt_users table (for foreign key constraint)
+ dbUserID, err := models.EnsureDBUser(db, dbUser)
+ if err != nil {
+ slog.Error("failed to ensure DB user exists", "error", err)
+ os.Exit(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,
)
- db, err := setupDatabase(cfg.Database)
- if err != nil {
- slog.Error("failed to connect to database", "error", err)
- os.Exit(1)
- }
-
if *migrate {
slog.Info("running database migrations...")
if err := models.Migrate(db); err != nil {
@@ -60,17 +104,11 @@ func main() {
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, err := setupRouter(db, cfg, local, dbUserID)
if err != nil {
slog.Error("failed to setup router", "error", err)
os.Exit(1)
@@ -107,6 +145,96 @@ 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) {
+ setupHandler, err := handlers.NewSetupHandler(local, "web/templates")
+ 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)
+ <-quit
+
+ slog.Info("setup mode server stopped")
+}
+
func setupLogger(cfg config.LoggingConfig) {
var level slog.Level
switch cfg.Level {
@@ -132,10 +260,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,39 +275,46 @@ 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(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUserID uint) (*gin.Engine, error) {
// 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)
+ pricelistRepo := repository.NewPricelistRepository(db)
+ configRepo := repository.NewConfigurationRepository(db)
// 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)
+ pricelistService := pricelist.NewService(db, pricelistRepo, componentRepo)
+ configService := services.NewConfigurationService(configRepo, componentRepo, quoteService)
+ syncService := sync.NewService(pricelistRepo, local)
// Handlers
- authHandler := handlers.NewAuthHandler(authService, userRepo)
componentHandler := handlers.NewComponentHandler(componentService)
quoteHandler := handlers.NewQuoteHandler(quoteService)
- configHandler := handlers.NewConfigurationHandler(configService, exportService)
exportHandler := handlers.NewExportHandler(exportService, configService, componentService)
pricingHandler := handlers.NewPricingHandler(db, pricingService, alertService, componentRepo, priceRepo, statsRepo)
+ pricelistHandler := handlers.NewPricelistHandler(pricelistService, local)
+ syncHandler := handlers.NewSyncHandler(local, syncService, db)
+
+ // Setup handler (for reconfiguration)
+ setupHandler, err := handlers.NewSetupHandler(local, "web/templates")
+ if err != nil {
+ return nil, fmt.Errorf("creating setup handler: %w", err)
+ }
// Web handler (templates)
webHandler, err := handlers.NewWebHandler("web/templates", componentService)
@@ -192,6 +327,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(db, local))
// Static files
router.Static("/static", "web/static")
@@ -229,14 +365,30 @@ func setupRouter(db *gorm.DB, cfg *config.Config) (*gin.Engine, error) {
"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", webHandler.Pricelists)
+ router.GET("/pricelists/:id", webHandler.PricelistDetail)
router.GET("/admin/pricing", webHandler.AdminPricing)
// htmx partials
@@ -252,16 +404,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 +414,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,6 +576,15 @@ 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.POST("/components", syncHandler.SyncComponents)
+ syncAPI.POST("/pricelists", syncHandler.SyncPricelists)
+ syncAPI.POST("/all", syncHandler.SyncAll)
+ }
}
return router, nil
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/handlers/pricelist.go b/internal/handlers/pricelist.go
new file mode 100644
index 0000000..ca91936
--- /dev/null
+++ b/internal/handlers/pricelist.go
@@ -0,0 +1,134 @@
+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
+ }
+
+ 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) {
+ pl, err := h.service.GetLatestActive()
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "no active pricelists found"})
+ return
+ }
+
+ c.JSON(http.StatusOK, pl)
+}
diff --git a/internal/handlers/setup.go b/internal/handlers/setup.go
new file mode 100644
index 0000000..0226a15
--- /dev/null
+++ b/internal/handlers/setup.go
@@ -0,0 +1,196 @@
+package handlers
+
+import (
+ "fmt"
+ "html/template"
+ "net/http"
+ "path/filepath"
+ "strconv"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "git.mchus.pro/mchus/quoteforge/internal/localdb"
+ "gorm.io/driver/mysql"
+ "gorm.io/gorm"
+ "gorm.io/gorm/logger"
+)
+
+type SetupHandler struct {
+ localDB *localdb.LocalDB
+ templates map[string]*template.Template
+}
+
+func NewSetupHandler(localDB *localdb.LocalDB, templatesPath string) (*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,
+ templates: templates,
+ }, 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
+ }
+
+ 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
+ }
+
+ // 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
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "Settings saved. Please restart the application.",
+ })
+}
+
+// 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..87abf2b
--- /dev/null
+++ b/internal/handlers/sync.go
@@ -0,0 +1,217 @@
+package handlers
+
+import (
+ "log/slog"
+ "net/http"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "git.mchus.pro/mchus/quoteforge/internal/localdb"
+ "git.mchus.pro/mchus/quoteforge/internal/services/sync"
+ "gorm.io/gorm"
+)
+
+// SyncHandler handles sync API endpoints
+type SyncHandler struct {
+ localDB *localdb.LocalDB
+ syncService *sync.Service
+ mariaDB *gorm.DB
+}
+
+// NewSyncHandler creates a new sync handler
+func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, mariaDB *gorm.DB) *SyncHandler {
+ return &SyncHandler{
+ localDB: localDB,
+ syncService: syncService,
+ mariaDB: mariaDB,
+ }
+}
+
+// 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
+ }
+
+ result, err := h.localDB.SyncComponents(h.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
+ compResult, err := h.localDB.SyncComponents(h.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 {
+ sqlDB, err := h.mariaDB.DB()
+ if err != nil {
+ return false
+ }
+
+ if err := sqlDB.Ping(); err != nil {
+ return false
+ }
+
+ return true
+}
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..666cf96
--- /dev/null
+++ b/internal/localdb/components.go
@@ -0,0 +1,268 @@
+package localdb
+
+import (
+ "fmt"
+ "log/slog"
+ "strings"
+ "time"
+
+ "gorm.io/gorm"
+)
+
+// 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
+}
+
+// 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)
+}
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..b316bf3
--- /dev/null
+++ b/internal/localdb/localdb.go
@@ -0,0 +1,339 @@
+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{},
+ ); 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
+ }
+
+ dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
+ 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
+}
diff --git a/internal/localdb/models.go b/internal/localdb/models.go
new file mode 100644
index 0000000..bbad5e6
--- /dev/null
+++ b/internal/localdb/models.go
@@ -0,0 +1,122 @@
+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"`
+ 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"
+}
diff --git a/internal/middleware/offline.go b/internal/middleware/offline.go
new file mode 100644
index 0000000..a1c5ff1
--- /dev/null
+++ b/internal/middleware/offline.go
@@ -0,0 +1,43 @@
+package middleware
+
+import (
+ "log/slog"
+
+ "github.com/gin-gonic/gin"
+ "git.mchus.pro/mchus/quoteforge/internal/localdb"
+ "gorm.io/gorm"
+)
+
+// 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(mariaDB *gorm.DB, local *localdb.LocalDB) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ isOffline := !checkMariaDBOnline(mariaDB)
+
+ // 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()
+ }
+}
+
+// checkMariaDBOnline checks if MariaDB is accessible
+func checkMariaDBOnline(mariaDB *gorm.DB) bool {
+ sqlDB, err := mariaDB.DB()
+ if err != nil {
+ return false
+ }
+
+ if err := sqlDB.Ping(); err != nil {
+ return false
+ }
+
+ return true
+}
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/services/configuration.go b/internal/services/configuration.go
index 764073c..d49a5b1 100644
--- a/internal/services/configuration.go
+++ b/internal/services/configuration.go
@@ -194,6 +194,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/pricelist/service.go b/internal/services/pricelist/service.go
new file mode 100644
index 0000000..4b17139
--- /dev/null
+++ b/internal/services/pricelist/service.go
@@ -0,0 +1,156 @@
+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) {
+ 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 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) {
+ 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 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 {
+ return s.repo.Delete(id)
+}
+
+// CanWrite returns true if the user can create pricelists
+func (s *Service) CanWrite() bool {
+ return s.repo.CanWrite()
+}
+
+// CanWriteDebug returns write permission status with debug info
+func (s *Service) CanWriteDebug() (bool, string) {
+ return s.repo.CanWriteDebug()
+}
+
+// GetLatestActive returns the most recent active pricelist
+func (s *Service) GetLatestActive() (*models.Pricelist, error) {
+ return s.repo.GetLatestActive()
+}
+
+// CleanupExpired deletes expired and unused pricelists
+func (s *Service) CleanupExpired() (int, error) {
+ 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..661a7ac
--- /dev/null
+++ b/internal/services/sync/service.go
@@ -0,0 +1,215 @@
+package sync
+
+import (
+ "fmt"
+ "log/slog"
+ "time"
+
+ "git.mchus.pro/mchus/quoteforge/internal/localdb"
+ "git.mchus.pro/mchus/quoteforge/internal/repository"
+)
+
+// Service handles synchronization between MariaDB and local SQLite
+type Service struct {
+ pricelistRepo *repository.PricelistRepository
+ localDB *localdb.LocalDB
+}
+
+// NewService creates a new sync service
+func NewService(pricelistRepo *repository.PricelistRepository, localDB *localdb.LocalDB) *Service {
+ return &Service{
+ pricelistRepo: pricelistRepo,
+ 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
+ serverPricelists, _, err := s.pricelistRepo.List(0, 1)
+ if err != nil {
+ return nil, fmt.Errorf("counting server pricelists: %w", err)
+ }
+
+ // Count local pricelists
+ localCount := s.localDB.CountLocalPricelists()
+
+ needsSync, _ := s.NeedSync()
+
+ return &SyncStatus{
+ LastSyncAt: lastSync,
+ ServerPricelists: len(serverPricelists),
+ 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
+ latestServer, err := s.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 all active pricelists from server (up to 100)
+ serverPricelists, _, err := s.pricelistRepo.List(0, 100)
+ if err != nil {
+ return 0, fmt.Errorf("getting server pricelists: %w", err)
+ }
+
+ synced := 0
+ for _, pl := range serverPricelists {
+ // Check if pricelist already exists locally
+ existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
+ if existing != nil {
+ // Already synced, skip
+ 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
+ }
+
+ synced++
+ slog.Debug("synced pricelist", "version", pl.Version, "server_id", pl.ID)
+ }
+
+ // 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 items from server
+ serverItems, _, err := s.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
+}
diff --git a/web/templates/admin_pricing.html b/web/templates/admin_pricing.html
index 5423453..be71401 100644
--- a/web/templates/admin_pricing.html
+++ b/web/templates/admin_pricing.html
@@ -187,21 +187,11 @@ async function loadTab(tab) {
}
async function loadData() {
- const token = localStorage.getItem('token');
- if (!token) {
- window.location.href = '/login';
- return;
- }
-
document.getElementById('tab-content').innerHTML = 'Загрузка...
';
try {
if (currentTab === 'alerts') {
- const resp = await fetch('/admin/pricing/alerts?per_page=100', {
- headers: {'Authorization': 'Bearer ' + token}
- });
- if (resp.status === 401) { logout(); return; }
- if (resp.status === 403) { window.location.href = '/'; return; }
+ const resp = await fetch('/api/admin/pricing/alerts?per_page=100');
const data = await resp.json();
renderAlerts(data.alerts || []);
} else if (currentTab === 'all-configs') {
@@ -210,17 +200,13 @@ async function loadData() {
if (currentSearch) {
url += '&search=' + encodeURIComponent(currentSearch);
}
- const resp = await fetch(url, {
- headers: {'Authorization': 'Bearer ' + token}
- });
- if (resp.status === 401) { logout(); return; }
- if (resp.status === 403) { window.location.href = '/'; return; }
+ const resp = await fetch(url);
const data = await resp.json();
totalPages = Math.ceil(data.total / perPage);
renderAllConfigs(data.configurations || []);
updatePagination(data.total);
} else {
- let url = '/admin/pricing/components?page=' + currentPage + '&per_page=' + perPage;
+ let url = '/api/admin/pricing/components?page=' + currentPage + '&per_page=' + perPage;
if (currentSearch) {
url += '&search=' + encodeURIComponent(currentSearch);
}
@@ -230,10 +216,7 @@ async function loadData() {
if (sortDir) {
url += '&dir=' + encodeURIComponent(sortDir);
}
- const resp = await fetch(url, {
- headers: {'Authorization': 'Bearer ' + token}
- });
- if (resp.status === 401) { logout(); return; }
+ const resp = await fetch(url);
const data = await resp.json();
totalPages = Math.ceil(data.total / perPage);
componentsCache = data.components || [];
@@ -471,9 +454,6 @@ function onMethodChange() {
}
async function fetchPreview() {
- const token = localStorage.getItem('token');
- if (!token) return;
-
const lotName = document.getElementById('modal-lot-name').value;
const method = document.getElementById('modal-method').value;
const periodDays = parseInt(document.getElementById('modal-period').value) || 0;
@@ -490,10 +470,9 @@ async function fetchPreview() {
}
try {
- const resp = await fetch('/admin/pricing/preview', {
+ const resp = await fetch('/api/admin/pricing/preview', {
method: 'POST',
headers: {
- 'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({
@@ -508,8 +487,6 @@ async function fetchPreview() {
})
});
- if (resp.status === 401) { logout(); return; }
-
if (resp.ok) {
const data = await resp.json();
@@ -584,12 +561,6 @@ function debounceFetchPreview() {
}
async function savePrice() {
- const token = localStorage.getItem('token');
- if (!token) {
- window.location.href = '/login';
- return;
- }
-
const lotName = document.getElementById('modal-lot-name').value;
const method = document.getElementById('modal-method').value;
const periodDaysStr = document.getElementById('modal-period').value;
@@ -630,17 +601,14 @@ async function savePrice() {
}
try {
- const resp = await fetch('/admin/pricing/update', {
+ const resp = await fetch('/api/admin/pricing/update', {
method: 'POST',
headers: {
- 'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
- if (resp.status === 401) { logout(); return; }
-
if (resp.ok) {
closeModal();
loadData();
@@ -683,12 +651,6 @@ function processMetaPrices(metaPrices, originalLotName) {
}
function recalculateAll() {
- const token = localStorage.getItem('token');
- if (!token) {
- window.location.href = '/login';
- return;
- }
-
const btn = document.getElementById('btn-recalc');
const progressContainer = document.getElementById('progress-container');
const progressBar = document.getElementById('progress-bar');
@@ -707,9 +669,8 @@ function recalculateAll() {
progressStats.textContent = 'Подготовка...';
// Use fetch with streaming for SSE
- fetch('/admin/pricing/recalculate-all', {
- method: 'POST',
- headers: {'Authorization': 'Bearer ' + token}
+ fetch('/api/admin/pricing/recalculate-all', {
+ method: 'POST'
}).then(response => {
const reader = response.body.getReader();
const decoder = new TextDecoder();
diff --git a/web/templates/base.html b/web/templates/base.html
index a7d7137..ca7692f 100644
--- a/web/templates/base.html
+++ b/web/templates/base.html
@@ -19,18 +19,18 @@
-
@@ -50,33 +50,6 @@
+
+
+
+
QuoteForge
+
Настройка подключения к базе данных
+
+
+
+
+
+
+ QuoteForge v1.0 - Конфигуратор серверов
+
+
+
+
+
diff --git a/web/templates/configs.html b/web/templates/configs.html
index 246d8fd..664b552 100644
--- a/web/templates/configs.html
+++ b/web/templates/configs.html
@@ -99,38 +99,10 @@
+{{end}}
+
+{{template "base" .}}
diff --git a/web/templates/pricelists.html b/web/templates/pricelists.html
new file mode 100644
index 0000000..126d376
--- /dev/null
+++ b/web/templates/pricelists.html
@@ -0,0 +1,234 @@
+{{define "title"}}Прайслисты - QuoteForge{{end}}
+
+{{define "content"}}
+
+
+
+{{end}}
+
+{{template "base" .}}
diff --git a/web/templates/setup.html b/web/templates/setup.html
new file mode 100644
index 0000000..5d211f9
--- /dev/null
+++ b/web/templates/setup.html
@@ -0,0 +1,153 @@
+{{define "setup.html"}}
+
+
+