Files
QuoteForge/internal/repository/pricelist.go
Michael Chus 143d217397 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 <noreply@anthropic.com>
2026-02-01 11:00:32 +03:00

260 lines
7.9 KiB
Go

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
}