Fixed two critical issues preventing offline-first operation: 1. **Instant startup** - Removed blocking GetDB() call during server initialization. Server now starts in <10ms instead of 1+ minute. - Changed setupRouter() to use lazy DB connection via ConnectionManager - mariaDB connection is now nil on startup, established only when needed - Fixes timeout issues when MariaDB is unreachable 2. **Offline mode nil pointer panics** - Added graceful degradation when database is offline: - ComponentService.GetCategories() returns DefaultCategories if repo is nil - ComponentService.List/GetByLotName checks for nil repo - PricelistService methods return empty/error responses in offline mode - All methods properly handle nil repositories **Before**: Server startup took 1min+ and crashed with nil pointer panic when trying to load /configurator page offline. **After**: Server starts instantly and serves pages in offline mode using DefaultCategories and SQLite data. Related to Phase 2.5: Full Offline Mode (local-first architecture) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
188 lines
4.8 KiB
Go
188 lines
4.8 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) {
|
|
if s.repo == nil || s.db == nil {
|
|
return nil, fmt.Errorf("offline mode: cannot create pricelists")
|
|
}
|
|
|
|
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 no database connection (offline mode), return empty list
|
|
if s.repo == nil {
|
|
return []models.PricelistSummary{}, 0, nil
|
|
}
|
|
|
|
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) {
|
|
if s.repo == nil {
|
|
return nil, fmt.Errorf("offline mode: pricelist service not available")
|
|
}
|
|
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 s.repo == nil {
|
|
return []models.PricelistItem{}, 0, nil
|
|
}
|
|
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 {
|
|
if s.repo == nil {
|
|
return fmt.Errorf("offline mode: cannot delete pricelists")
|
|
}
|
|
return s.repo.Delete(id)
|
|
}
|
|
|
|
// CanWrite returns true if the user can create pricelists
|
|
func (s *Service) CanWrite() bool {
|
|
if s.repo == nil {
|
|
return false
|
|
}
|
|
return s.repo.CanWrite()
|
|
}
|
|
|
|
// CanWriteDebug returns write permission status with debug info
|
|
func (s *Service) CanWriteDebug() (bool, string) {
|
|
if s.repo == nil {
|
|
return false, "offline mode"
|
|
}
|
|
return s.repo.CanWriteDebug()
|
|
}
|
|
|
|
// GetLatestActive returns the most recent active pricelist
|
|
func (s *Service) GetLatestActive() (*models.Pricelist, error) {
|
|
if s.repo == nil {
|
|
return nil, fmt.Errorf("offline mode: pricelist service not available")
|
|
}
|
|
return s.repo.GetLatestActive()
|
|
}
|
|
|
|
// CleanupExpired deletes expired and unused pricelists
|
|
func (s *Service) CleanupExpired() (int, error) {
|
|
if s.repo == nil {
|
|
return 0, fmt.Errorf("offline mode: cleanup not available")
|
|
}
|
|
|
|
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
|
|
}
|