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>
157 lines
4.0 KiB
Go
157 lines
4.0 KiB
Go
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
|
|
}
|