# QuoteForge - Claude Code Instructions ## Project Overview QuoteForge — корпоративный инструмент для конфигурирования серверов и формирования коммерческих предложений (КП). Приложение работает с серверной базой данных MariaDB (RFQ_LOG) и локальной SQLite для оффлайн-работы. ## Development Phases ### Phase 1: Pricelists in MariaDB - Настройка подключения к БД при первом запуске - Таблицы qt_pricelists и qt_pricelist_items - CRUD операции для прайслистов (при наличии прав записи) ### Phase 2: Projects and Specifications - Таблицы qt_projects и qt_specifications - Замена qt_configurations на новую структуру - Поля: opty, customer_requirement, variant, qty, rev ### Phase 3: Local SQLite Database - Локальное хранение настроек подключения - Кэширование прайслистов - Локальные проекты и спецификации - Синхронизация с сервером ### Phase 4: Price Versioning - Привязка спецификаций к версиям прайслистов - Актуализация прайслистов с показом разницы цен - Автоочистка старых прайслистов (>1 года, usage_count=0) ## Tech Stack - **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) ## Project Structure ``` 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 ``` ## Existing Database Tables (READ-ONLY - DO NOT MODIFY) These tables are used by other systems. Our app only reads from them: ```sql -- Component catalog CREATE TABLE lot ( lot_name CHAR(255) PRIMARY KEY, lot_description VARCHAR(10000) ); -- 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) ); -- Supplier catalog CREATE TABLE supplier ( supplier_name CHAR(255) PRIMARY KEY, supplier_comment VARCHAR(10000) ); ``` ## 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 } ``` ## 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 ## 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 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 - 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