- Add development phases (pricelists, projects, local SQLite, price versioning) - Add new table schemas (qt_pricelists, qt_projects, qt_specifications) - Add local SQLite database structure for offline work - Remove Docker files (distributing as binary only) - Disable RBAC for initial phases Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
21 KiB
21 KiB
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:
-- 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
-- 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
-- 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
-- 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)
-- 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:
-- 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
// 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
// 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
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
// 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
// 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
// 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
// "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
func CalculateMedian(prices []float64) float64
func CalculateAverage(prices []float64) float64
func CalculateWeightedMedian(prices []PricePoint, decayDays int) float64
9. Price Freshness
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- freshtext-yellow-600 bg-yellow-50- normaltext-orange-600 bg-orange-50- staletext-red-600 bg-red-50- critical
- Sync status indicator in header
- Offline mode indicator when server unavailable
Commands
# 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:
- Create a default project for orphan configurations
- Create initial pricelist from current qt_lot_metadata prices
- Convert qt_configurations to qt_specifications with default variant="Base", rev=1
- 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