refactor: единый источник категории LOT — local_pricelist_items.lot_category
Удалён мёртвый серверный слой управления компонентами/категориями, который дублировал источники категории LOT. В рантайме категория всегда берётся из local_pricelist_items.lot_category (наполняется синком из qt_pricelist_items.lot_category). Удалено: - repository: UnifiedRepo/DataSource, ComponentRepository, CategoryRepository - services: старый ConfigurationService (заменён LocalConfigurationService), ComponentService, ComponentToLocal, ImportFromLot, ParsePartNumber - quote.go: недостижимый online-блок (qt_categories) + componentRepo/ pricingService/priceResolver Сохранены живые типы: models.Category/DefaultCategories (для /api/categories), ComponentView/ComponentListResult, CreateConfigRequest/ArticlePreviewRequest/ ConfigurationGetter/ErrConfig*. bible-local/03-database.md: зафиксирован единственный источник категории LOT; qt_categories/qt_lot_metadata перенесены в server-side only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,7 @@ Rules:
|
||||
- cache tables may be rebuilt if local migration recovery requires it;
|
||||
- user-authored tables must not be dropped as a recovery shortcut;
|
||||
- `local_pricelist_items` is the only valid runtime source of prices and component catalog; do not add a separate component cache table;
|
||||
- `local_pricelist_items.lot_category` is the single source of a LOT's category at runtime (populated by sync from `qt_pricelist_items.lot_category`); do not derive category from a lot_name prefix or from `qt_categories`/`qt_lot_metadata`;
|
||||
- configuration `items` and `vendor_spec` are stored as JSON payloads inside configuration rows;
|
||||
- `local_components` table has been removed; any reference to it is dead code.
|
||||
|
||||
@@ -35,8 +36,6 @@ MariaDB is the central sync database (`RFQ_LOG`). Final schema as of 2026-04-15.
|
||||
### QuoteForge tables (qt_*)
|
||||
|
||||
Runtime read:
|
||||
- `qt_categories` — pricelist categories (note: `name`/`name_ru` columns being removed; QF does not use them)
|
||||
- `qt_lot_metadata` — component metadata, price settings
|
||||
- `qt_pricelists` — pricelist headers (source: estimate / warehouse / competitor)
|
||||
- `qt_pricelist_items` — pricelist rows
|
||||
- `qt_partnumber_books` — partnumber book headers
|
||||
@@ -53,6 +52,8 @@ Insert-only tracking:
|
||||
- `qt_vendor_partnumber_seen` — vendor partnumbers encountered during sync; `lot_suggestion` column updated when user manually maps PN → LOT in vendor-spec UI
|
||||
|
||||
Server-side only (not queried by client runtime):
|
||||
- `qt_categories` — pricelist category registry; QF runtime serves category lists for the UI from `models.DefaultCategories` (Go) overlaid with categories present in `local_pricelist_items`, not from this table. `name`/`name_ru` columns being removed.
|
||||
- `qt_lot_metadata` — component metadata / price settings; the Go server-side component/category management layer (`ComponentRepository`, `CategoryRepository`, `ComponentService`) was removed — no client code reads this table
|
||||
- `qt_component_usage_stats` — aggregated component popularity stats (written by server jobs)
|
||||
- `qt_pricing_alerts` — price anomaly alerts (models exist in Go; feature disabled in runtime)
|
||||
- `qt_schema_migrations` — server migration history (applied via `go run ./cmd/qfs -migrate`)
|
||||
|
||||
@@ -677,8 +677,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
var projectService *services.ProjectService
|
||||
|
||||
syncService = sync.NewService(connMgr, local)
|
||||
componentService := services.NewComponentService(nil, nil)
|
||||
quoteService := services.NewQuoteService(nil, nil, local, nil)
|
||||
quoteService := services.NewQuoteService(nil, local)
|
||||
exportService := services.NewExportService(cfg.Export, local)
|
||||
|
||||
// isOnline function for local-first architecture
|
||||
@@ -775,7 +774,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
templatesPath := filepath.Join("web", "templates")
|
||||
|
||||
// Handlers
|
||||
componentHandler := handlers.NewComponentHandler(componentService, local)
|
||||
componentHandler := handlers.NewComponentHandler(local)
|
||||
quoteHandler := handlers.NewQuoteHandler(quoteService)
|
||||
exportHandler := handlers.NewExportHandler(exportService, configService, projectService, dbUsername)
|
||||
pricelistHandler := handlers.NewPricelistHandler(local)
|
||||
|
||||
@@ -7,20 +7,17 @@ import (
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ComponentHandler struct {
|
||||
componentService *services.ComponentService
|
||||
localDB *localdb.LocalDB
|
||||
localDB *localdb.LocalDB
|
||||
}
|
||||
|
||||
func NewComponentHandler(componentService *services.ComponentService, localDB *localdb.LocalDB) *ComponentHandler {
|
||||
func NewComponentHandler(localDB *localdb.LocalDB) *ComponentHandler {
|
||||
return &ComponentHandler{
|
||||
componentService: componentService,
|
||||
localDB: localDB,
|
||||
localDB: localDB,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,17 +31,10 @@ func (h *ComponentHandler) List(c *gin.Context) {
|
||||
perPage = 20
|
||||
}
|
||||
|
||||
filter := repository.ComponentFilter{
|
||||
Category: c.Query("category"),
|
||||
Search: c.Query("search"),
|
||||
HasPrice: c.Query("has_price") == "true",
|
||||
ExcludeHidden: c.Query("include_hidden") != "true", // По умолчанию скрытые не показываются
|
||||
}
|
||||
|
||||
localFilter := localdb.ComponentFilter{
|
||||
Category: filter.Category,
|
||||
Search: filter.Search,
|
||||
HasPrice: filter.HasPrice,
|
||||
Category: c.Query("category"),
|
||||
Search: c.Query("search"),
|
||||
HasPrice: c.Query("has_price") == "true",
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
localComps, total, err := h.localDB.ListComponents(localFilter, offset, perPage)
|
||||
|
||||
@@ -294,33 +294,6 @@ func LocalToPricelistItem(local *LocalPricelistItem, serverPricelistID uint) *mo
|
||||
}
|
||||
}
|
||||
|
||||
// ComponentToLocal converts models.LotMetadata to LocalComponent
|
||||
func ComponentToLocal(meta *models.LotMetadata) *LocalComponent {
|
||||
var lotDesc string
|
||||
var category string
|
||||
|
||||
if meta.Lot != nil {
|
||||
lotDesc = meta.Lot.LotDescription
|
||||
}
|
||||
|
||||
// Extract category from lot_name (e.g., "CPU_AMD_9654" -> "CPU")
|
||||
if len(meta.LotName) > 0 {
|
||||
for i, ch := range meta.LotName {
|
||||
if ch == '_' {
|
||||
category = meta.LotName[:i]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &LocalComponent{
|
||||
LotName: meta.LotName,
|
||||
LotDescription: lotDesc,
|
||||
Category: category,
|
||||
Model: meta.Model,
|
||||
}
|
||||
}
|
||||
|
||||
// LocalToComponent converts LocalComponent to models.LotMetadata
|
||||
func LocalToComponent(local *LocalComponent) *models.LotMetadata {
|
||||
return &models.LotMetadata{
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CategoryRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewCategoryRepository(db *gorm.DB) *CategoryRepository {
|
||||
return &CategoryRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *CategoryRepository) GetAll() ([]models.Category, error) {
|
||||
var categories []models.Category
|
||||
err := r.db.Order("display_order ASC").Find(&categories).Error
|
||||
return categories, err
|
||||
}
|
||||
|
||||
func (r *CategoryRepository) GetByCode(code string) (*models.Category, error) {
|
||||
var category models.Category
|
||||
err := r.db.Where("code = ?", code).First(&category).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &category, nil
|
||||
}
|
||||
|
||||
func (r *CategoryRepository) GetByID(id uint) (*models.Category, error) {
|
||||
var category models.Category
|
||||
err := r.db.First(&category, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &category, nil
|
||||
}
|
||||
|
||||
// CreateIfNotExists creates a new category if it doesn't exist, returns existing one if it does
|
||||
func (r *CategoryRepository) CreateIfNotExists(code string) (*models.Category, error) {
|
||||
// Try to find existing
|
||||
existing, err := r.GetByCode(code)
|
||||
if err == nil {
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
// Get max display order to put new category at the end
|
||||
var maxOrder int
|
||||
r.db.Model(&models.Category{}).Select("COALESCE(MAX(display_order), 0)").Scan(&maxOrder)
|
||||
|
||||
// Create new category
|
||||
newCat := &models.Category{
|
||||
Code: code,
|
||||
Name: code, // Use code as name initially
|
||||
NameRu: code,
|
||||
DisplayOrder: maxOrder + 1,
|
||||
IsRequired: false,
|
||||
}
|
||||
|
||||
if err := r.db.Create(newCat).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newCat, nil
|
||||
}
|
||||
|
||||
// Create creates a new category
|
||||
func (r *CategoryRepository) Create(category *models.Category) error {
|
||||
return r.db.Create(category).Error
|
||||
}
|
||||
|
||||
// Update updates an existing category
|
||||
func (r *CategoryRepository) Update(category *models.Category) error {
|
||||
return r.db.Save(category).Error
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ComponentRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewComponentRepository(db *gorm.DB) *ComponentRepository {
|
||||
return &ComponentRepository{db: db}
|
||||
}
|
||||
|
||||
type ComponentFilter struct {
|
||||
Category string
|
||||
Search string
|
||||
HasPrice bool
|
||||
ExcludeHidden bool
|
||||
SortField string
|
||||
SortDir string
|
||||
}
|
||||
|
||||
func (r *ComponentRepository) List(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) {
|
||||
var components []models.LotMetadata
|
||||
var total int64
|
||||
|
||||
query := r.db.Model(&models.LotMetadata{}).
|
||||
Preload("Lot").
|
||||
Preload("Category")
|
||||
|
||||
if filter.Category != "" {
|
||||
query = query.Joins("JOIN qt_categories ON qt_lot_metadata.category_id = qt_categories.id").
|
||||
Where("qt_categories.code = ?", filter.Category)
|
||||
}
|
||||
if filter.Search != "" {
|
||||
search := "%" + filter.Search + "%"
|
||||
query = query.Where("lot_name LIKE ? OR model LIKE ?", search, search)
|
||||
}
|
||||
if filter.HasPrice {
|
||||
query = query.Where("current_price IS NOT NULL AND current_price > 0")
|
||||
}
|
||||
if filter.ExcludeHidden {
|
||||
query = query.Where("is_hidden = ? OR is_hidden IS NULL", false)
|
||||
}
|
||||
|
||||
query.Count(&total)
|
||||
|
||||
// Apply sorting
|
||||
sortDir := "ASC"
|
||||
if filter.SortDir == "desc" {
|
||||
sortDir = "DESC"
|
||||
}
|
||||
|
||||
switch filter.SortField {
|
||||
case "popularity_score":
|
||||
query = query.Order("popularity_score " + sortDir)
|
||||
case "current_price":
|
||||
query = query.Order("CASE WHEN current_price IS NULL OR current_price = 0 THEN 1 ELSE 0 END").
|
||||
Order("current_price " + sortDir)
|
||||
case "lot_name":
|
||||
query = query.Order("lot_name " + sortDir)
|
||||
default:
|
||||
// Default: sort by popularity, no price goes last
|
||||
query = query.
|
||||
Order("CASE WHEN current_price IS NULL OR current_price = 0 THEN 1 ELSE 0 END").
|
||||
Order("popularity_score DESC")
|
||||
}
|
||||
|
||||
err := query.
|
||||
Offset(offset).
|
||||
Limit(limit).
|
||||
Find(&components).Error
|
||||
|
||||
return components, total, err
|
||||
}
|
||||
|
||||
func (r *ComponentRepository) GetByLotName(lotName string) (*models.LotMetadata, error) {
|
||||
var component models.LotMetadata
|
||||
err := r.db.
|
||||
Preload("Lot").
|
||||
Preload("Category").
|
||||
Where("lot_name = ?", lotName).
|
||||
First(&component).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &component, nil
|
||||
}
|
||||
|
||||
func (r *ComponentRepository) GetMultiple(lotNames []string) ([]models.LotMetadata, error) {
|
||||
var components []models.LotMetadata
|
||||
err := r.db.
|
||||
Preload("Lot").
|
||||
Preload("Category").
|
||||
Where("lot_name IN ?", lotNames).
|
||||
Find(&components).Error
|
||||
return components, err
|
||||
}
|
||||
|
||||
func (r *ComponentRepository) Update(component *models.LotMetadata) error {
|
||||
return r.db.Save(component).Error
|
||||
}
|
||||
|
||||
func (r *ComponentRepository) DB() *gorm.DB {
|
||||
return r.db
|
||||
}
|
||||
|
||||
func (r *ComponentRepository) Create(component *models.LotMetadata) error {
|
||||
return r.db.Create(component).Error
|
||||
}
|
||||
|
||||
func (r *ComponentRepository) IncrementRequestCount(lotName string) error {
|
||||
now := time.Now()
|
||||
return r.db.Model(&models.LotMetadata{}).
|
||||
Where("lot_name = ?", lotName).
|
||||
Updates(map[string]interface{}{
|
||||
"request_count": gorm.Expr("request_count + 1"),
|
||||
"last_request_date": now,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// GetAllLots returns all lots from the existing lot table
|
||||
func (r *ComponentRepository) GetAllLots() ([]models.Lot, error) {
|
||||
var lots []models.Lot
|
||||
err := r.db.Find(&lots).Error
|
||||
return lots, err
|
||||
}
|
||||
|
||||
// GetLotsWithoutMetadata returns lots that don't have qt_lot_metadata entries
|
||||
func (r *ComponentRepository) GetLotsWithoutMetadata() ([]models.Lot, error) {
|
||||
var lots []models.Lot
|
||||
err := r.db.
|
||||
Where("lot_name NOT IN (SELECT lot_name FROM qt_lot_metadata)").
|
||||
Find(&lots).Error
|
||||
return lots, err
|
||||
}
|
||||
@@ -1,393 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DataSource defines the unified interface for data access
|
||||
// It abstracts whether data comes from MariaDB (online) or SQLite (offline)
|
||||
type DataSource interface {
|
||||
// Components
|
||||
GetComponents(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error)
|
||||
GetComponent(lotName string) (*models.LotMetadata, error)
|
||||
|
||||
// Configurations
|
||||
SaveConfiguration(cfg *models.Configuration) error
|
||||
GetConfigurations(ownerUsername string) ([]models.Configuration, error)
|
||||
GetConfigurationByUUID(uuid string) (*models.Configuration, error)
|
||||
DeleteConfiguration(uuid string) error
|
||||
|
||||
// Pricelists (read-only in offline mode)
|
||||
GetPricelists() ([]models.PricelistSummary, error)
|
||||
GetPricelistByID(id uint) (*models.Pricelist, error)
|
||||
GetPricelistItems(pricelistID uint) ([]models.PricelistItem, error)
|
||||
GetLatestPricelist() (*models.Pricelist, error)
|
||||
}
|
||||
|
||||
// UnifiedRepo implements DataSource with automatic online/offline switching
|
||||
type UnifiedRepo struct {
|
||||
mariaDB *gorm.DB
|
||||
localDB *localdb.LocalDB
|
||||
isOnline bool
|
||||
}
|
||||
|
||||
// NewUnifiedRepo creates a new unified repository
|
||||
func NewUnifiedRepo(mariaDB *gorm.DB, localDB *localdb.LocalDB, isOnline bool) *UnifiedRepo {
|
||||
return &UnifiedRepo{
|
||||
mariaDB: mariaDB,
|
||||
localDB: localDB,
|
||||
isOnline: isOnline,
|
||||
}
|
||||
}
|
||||
|
||||
// SetOnlineStatus updates the online/offline status
|
||||
func (r *UnifiedRepo) SetOnlineStatus(online bool) {
|
||||
r.isOnline = online
|
||||
}
|
||||
|
||||
// IsOnline returns the current online/offline status
|
||||
func (r *UnifiedRepo) IsOnline() bool {
|
||||
return r.isOnline
|
||||
}
|
||||
|
||||
// Component methods
|
||||
|
||||
// GetComponents returns components from MariaDB (online) or local cache (offline)
|
||||
func (r *UnifiedRepo) GetComponents(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) {
|
||||
if r.isOnline {
|
||||
return r.getComponentsOnline(filter, offset, limit)
|
||||
}
|
||||
return r.getComponentsOffline(filter, offset, limit)
|
||||
}
|
||||
|
||||
func (r *UnifiedRepo) getComponentsOnline(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) {
|
||||
repo := NewComponentRepository(r.mariaDB)
|
||||
return repo.List(filter, offset, limit)
|
||||
}
|
||||
|
||||
func (r *UnifiedRepo) getComponentsOffline(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) {
|
||||
var components []localdb.LocalComponent
|
||||
query := r.localDB.DB().Model(&localdb.LocalComponent{})
|
||||
|
||||
// Apply filters
|
||||
if filter.Category != "" {
|
||||
query = query.Where("category = ?", filter.Category)
|
||||
}
|
||||
if filter.Search != "" {
|
||||
search := "%" + filter.Search + "%"
|
||||
query = query.Where("lot_name LIKE ? OR lot_description LIKE ? OR model LIKE ?", search, search, search)
|
||||
}
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
// Apply sorting
|
||||
sortDir := "ASC"
|
||||
if filter.SortDir == "desc" {
|
||||
sortDir = "DESC"
|
||||
}
|
||||
switch filter.SortField {
|
||||
case "lot_name":
|
||||
query = query.Order("lot_name " + sortDir)
|
||||
default:
|
||||
query = query.Order("lot_name ASC")
|
||||
}
|
||||
|
||||
if err := query.Offset(offset).Limit(limit).Find(&components).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("fetching offline components: %w", err)
|
||||
}
|
||||
|
||||
// Convert to models.LotMetadata
|
||||
result := make([]models.LotMetadata, len(components))
|
||||
for i, comp := range components {
|
||||
result[i] = models.LotMetadata{
|
||||
LotName: comp.LotName,
|
||||
Model: comp.Model,
|
||||
Lot: &models.Lot{
|
||||
LotName: comp.LotName,
|
||||
LotDescription: comp.LotDescription,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return result, total, nil
|
||||
}
|
||||
|
||||
// GetComponent returns a single component by lot name
|
||||
func (r *UnifiedRepo) GetComponent(lotName string) (*models.LotMetadata, error) {
|
||||
if r.isOnline {
|
||||
repo := NewComponentRepository(r.mariaDB)
|
||||
return repo.GetByLotName(lotName)
|
||||
}
|
||||
|
||||
var comp localdb.LocalComponent
|
||||
if err := r.localDB.DB().Where("lot_name = ?", lotName).First(&comp).Error; err != nil {
|
||||
return nil, fmt.Errorf("fetching offline component: %w", err)
|
||||
}
|
||||
|
||||
return &models.LotMetadata{
|
||||
LotName: comp.LotName,
|
||||
Model: comp.Model,
|
||||
Lot: &models.Lot{
|
||||
LotName: comp.LotName,
|
||||
LotDescription: comp.LotDescription,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Configuration methods
|
||||
|
||||
// SaveConfiguration saves a configuration (online: MariaDB, offline: SQLite + pending_changes)
|
||||
func (r *UnifiedRepo) SaveConfiguration(cfg *models.Configuration) error {
|
||||
if r.isOnline {
|
||||
repo := NewConfigurationRepository(r.mariaDB)
|
||||
return repo.Create(cfg)
|
||||
}
|
||||
|
||||
// Offline: save to local SQLite and queue for sync
|
||||
localCfg := &localdb.LocalConfiguration{
|
||||
UUID: cfg.UUID,
|
||||
Name: cfg.Name,
|
||||
TotalPrice: cfg.TotalPrice,
|
||||
CustomPrice: cfg.CustomPrice,
|
||||
Notes: cfg.Notes,
|
||||
IsTemplate: cfg.IsTemplate,
|
||||
ServerCount: cfg.ServerCount,
|
||||
CreatedAt: cfg.CreatedAt,
|
||||
UpdatedAt: time.Now(),
|
||||
SyncStatus: "pending",
|
||||
OriginalUsername: cfg.OwnerUsername,
|
||||
}
|
||||
|
||||
// Convert items
|
||||
localItems := make(localdb.LocalConfigItems, len(cfg.Items))
|
||||
for i, item := range cfg.Items {
|
||||
localItems[i] = localdb.LocalConfigItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: item.UnitPrice,
|
||||
}
|
||||
}
|
||||
localCfg.Items = localItems
|
||||
|
||||
if err := r.localDB.SaveConfiguration(localCfg); err != nil {
|
||||
return fmt.Errorf("saving local configuration: %w", err)
|
||||
}
|
||||
|
||||
// Add to pending changes queue
|
||||
payload, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling configuration for sync: %w", err)
|
||||
}
|
||||
|
||||
return r.localDB.AddPendingChange("configuration", cfg.UUID, "create", string(payload))
|
||||
}
|
||||
|
||||
// GetConfigurations returns all configurations for a user
|
||||
func (r *UnifiedRepo) GetConfigurations(ownerUsername string) ([]models.Configuration, error) {
|
||||
if r.isOnline {
|
||||
repo := NewConfigurationRepository(r.mariaDB)
|
||||
configs, _, err := repo.ListByUser(ownerUsername, 0, 1000)
|
||||
return configs, err
|
||||
}
|
||||
|
||||
// Offline: get from local SQLite
|
||||
localConfigs, err := r.localDB.GetConfigurations()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching local configurations: %w", err)
|
||||
}
|
||||
|
||||
// Convert to models.Configuration
|
||||
result := make([]models.Configuration, len(localConfigs))
|
||||
for i, lc := range localConfigs {
|
||||
items := make(models.ConfigItems, len(lc.Items))
|
||||
for j, item := range lc.Items {
|
||||
items[j] = models.ConfigItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: item.UnitPrice,
|
||||
}
|
||||
}
|
||||
|
||||
result[i] = models.Configuration{
|
||||
UUID: lc.UUID,
|
||||
OwnerUsername: lc.OriginalUsername,
|
||||
Name: lc.Name,
|
||||
Items: items,
|
||||
TotalPrice: lc.TotalPrice,
|
||||
CustomPrice: lc.CustomPrice,
|
||||
Notes: lc.Notes,
|
||||
IsTemplate: lc.IsTemplate,
|
||||
ServerCount: lc.ServerCount,
|
||||
CreatedAt: lc.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetConfigurationByUUID returns a configuration by UUID
|
||||
func (r *UnifiedRepo) GetConfigurationByUUID(uuid string) (*models.Configuration, error) {
|
||||
if r.isOnline {
|
||||
repo := NewConfigurationRepository(r.mariaDB)
|
||||
return repo.GetByUUID(uuid)
|
||||
}
|
||||
|
||||
localCfg, err := r.localDB.GetConfigurationByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching local configuration: %w", err)
|
||||
}
|
||||
|
||||
items := make(models.ConfigItems, len(localCfg.Items))
|
||||
for i, item := range localCfg.Items {
|
||||
items[i] = models.ConfigItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: item.UnitPrice,
|
||||
}
|
||||
}
|
||||
|
||||
return &models.Configuration{
|
||||
UUID: localCfg.UUID,
|
||||
Name: localCfg.Name,
|
||||
Items: items,
|
||||
TotalPrice: localCfg.TotalPrice,
|
||||
CustomPrice: localCfg.CustomPrice,
|
||||
Notes: localCfg.Notes,
|
||||
IsTemplate: localCfg.IsTemplate,
|
||||
ServerCount: localCfg.ServerCount,
|
||||
CreatedAt: localCfg.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteConfiguration deletes a configuration
|
||||
func (r *UnifiedRepo) DeleteConfiguration(uuid string) error {
|
||||
if r.isOnline {
|
||||
// Get ID first
|
||||
cfg, err := r.GetConfigurationByUUID(uuid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
repo := NewConfigurationRepository(r.mariaDB)
|
||||
return repo.Delete(cfg.ID)
|
||||
}
|
||||
|
||||
// Offline: delete from local and queue sync
|
||||
if err := r.localDB.DeleteConfiguration(uuid); err != nil {
|
||||
return fmt.Errorf("deleting local configuration: %w", err)
|
||||
}
|
||||
|
||||
return r.localDB.AddPendingChange("configuration", uuid, "delete", "")
|
||||
}
|
||||
|
||||
// Pricelist methods
|
||||
|
||||
// GetPricelists returns all pricelists
|
||||
func (r *UnifiedRepo) GetPricelists() ([]models.PricelistSummary, error) {
|
||||
if r.isOnline {
|
||||
repo := NewPricelistRepository(r.mariaDB)
|
||||
summaries, _, err := repo.List(0, 1000)
|
||||
return summaries, err
|
||||
}
|
||||
|
||||
// Offline: get from local cache
|
||||
localPLs, err := r.localDB.GetLocalPricelists()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching local pricelists: %w", err)
|
||||
}
|
||||
|
||||
summaries := make([]models.PricelistSummary, len(localPLs))
|
||||
for i, pl := range localPLs {
|
||||
itemCount := r.localDB.CountLocalPricelistItems(pl.ID)
|
||||
summaries[i] = models.PricelistSummary{
|
||||
ID: pl.ServerID,
|
||||
Version: pl.Version,
|
||||
CreatedAt: pl.CreatedAt,
|
||||
ItemCount: itemCount,
|
||||
}
|
||||
}
|
||||
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
// GetPricelistByID returns a pricelist by ID
|
||||
func (r *UnifiedRepo) GetPricelistByID(id uint) (*models.Pricelist, error) {
|
||||
if r.isOnline {
|
||||
repo := NewPricelistRepository(r.mariaDB)
|
||||
return repo.GetByID(id)
|
||||
}
|
||||
|
||||
// Offline: get from local cache
|
||||
localPL, err := r.localDB.GetLocalPricelistByServerID(id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching local pricelist: %w", err)
|
||||
}
|
||||
|
||||
itemCount := r.localDB.CountLocalPricelistItems(localPL.ID)
|
||||
return &models.Pricelist{
|
||||
ID: localPL.ServerID,
|
||||
Version: localPL.Version,
|
||||
CreatedAt: localPL.CreatedAt,
|
||||
ItemCount: int(itemCount),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetPricelistItems returns items for a pricelist
|
||||
func (r *UnifiedRepo) GetPricelistItems(pricelistID uint) ([]models.PricelistItem, error) {
|
||||
if r.isOnline {
|
||||
repo := NewPricelistRepository(r.mariaDB)
|
||||
items, _, err := repo.GetItems(pricelistID, 0, 100000, "")
|
||||
return items, err
|
||||
}
|
||||
|
||||
// Offline: get from local cache
|
||||
// First find the local pricelist by server ID
|
||||
localPL, err := r.localDB.GetLocalPricelistByServerID(pricelistID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching local pricelist: %w", err)
|
||||
}
|
||||
|
||||
localItems, err := r.localDB.GetLocalPricelistItems(localPL.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching local pricelist items: %w", err)
|
||||
}
|
||||
|
||||
items := make([]models.PricelistItem, len(localItems))
|
||||
for i, item := range localItems {
|
||||
items[i] = models.PricelistItem{
|
||||
ID: item.ID,
|
||||
PricelistID: pricelistID,
|
||||
LotName: item.LotName,
|
||||
Price: item.Price,
|
||||
}
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetLatestPricelist returns the latest pricelist
|
||||
func (r *UnifiedRepo) GetLatestPricelist() (*models.Pricelist, error) {
|
||||
if r.isOnline {
|
||||
repo := NewPricelistRepository(r.mariaDB)
|
||||
return repo.GetLatestActive()
|
||||
}
|
||||
|
||||
// Offline: get from local cache
|
||||
localPL, err := r.localDB.GetLatestLocalPricelist()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching latest local pricelist: %w", err)
|
||||
}
|
||||
|
||||
itemCount := r.localDB.CountLocalPricelistItems(localPL.ID)
|
||||
return &models.Pricelist{
|
||||
ID: localPL.ServerID,
|
||||
Version: localPL.Version,
|
||||
CreatedAt: localPL.CreatedAt,
|
||||
ItemCount: int(itemCount),
|
||||
}, nil
|
||||
}
|
||||
@@ -1,43 +1,9 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
)
|
||||
|
||||
type ComponentService struct {
|
||||
componentRepo *repository.ComponentRepository
|
||||
categoryRepo *repository.CategoryRepository
|
||||
}
|
||||
|
||||
func NewComponentService(
|
||||
componentRepo *repository.ComponentRepository,
|
||||
categoryRepo *repository.CategoryRepository,
|
||||
) *ComponentService {
|
||||
return &ComponentService{
|
||||
componentRepo: componentRepo,
|
||||
categoryRepo: categoryRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// ParsePartNumber extracts category and model from lot_name
|
||||
// "CPU_AMD_9654" → category="CPU", model="AMD_9654"
|
||||
// "MB_INTEL_4.Sapphire_2S_32xDDR5" → category="MB", model="INTEL_4.Sapphire_2S_32xDDR5"
|
||||
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
|
||||
}
|
||||
|
||||
type ComponentListResult struct {
|
||||
Items []ComponentView `json:"items"`
|
||||
TotalCount int64 `json:"total_count"`
|
||||
@@ -56,169 +22,3 @@ type ComponentView struct {
|
||||
PopularityScore float64 `json:"popularity_score"`
|
||||
Specs models.Specs `json:"specs,omitempty"`
|
||||
}
|
||||
|
||||
func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage int) (*ComponentListResult, error) {
|
||||
// If no database connection (offline mode), return empty list
|
||||
// Components should be loaded via /api/sync/components first
|
||||
if s.componentRepo == nil {
|
||||
return &ComponentListResult{
|
||||
Items: []ComponentView{},
|
||||
TotalCount: 0,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
TotalPages: 1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 {
|
||||
perPage = 20
|
||||
}
|
||||
if perPage > 5000 {
|
||||
perPage = 5000
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
|
||||
components, total, err := s.componentRepo.List(filter, offset, perPage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
views := make([]ComponentView, len(components))
|
||||
for i, c := range components {
|
||||
view := ComponentView{
|
||||
LotName: c.LotName,
|
||||
Model: c.Model,
|
||||
PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3),
|
||||
PopularityScore: c.PopularityScore,
|
||||
Specs: c.Specs,
|
||||
}
|
||||
|
||||
if c.Lot != nil {
|
||||
view.Description = c.Lot.LotDescription
|
||||
}
|
||||
if c.Category != nil {
|
||||
view.Category = c.Category.Code
|
||||
view.CategoryName = c.Category.Name
|
||||
}
|
||||
|
||||
views[i] = view
|
||||
}
|
||||
|
||||
totalPages := int((total + int64(perPage) - 1) / int64(perPage))
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
return &ComponentListResult{
|
||||
Items: views,
|
||||
TotalCount: total,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
TotalPages: totalPages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error) {
|
||||
// If no database connection (offline mode), return error
|
||||
if s.componentRepo == nil {
|
||||
return nil, fmt.Errorf("offline mode: component data not available")
|
||||
}
|
||||
|
||||
c, err := s.componentRepo.GetByLotName(lotName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Track usage (best-effort)
|
||||
if err := s.componentRepo.IncrementRequestCount(lotName); err != nil {
|
||||
slog.Warn("component: could not increment request count", "lot", lotName, "err", err)
|
||||
}
|
||||
|
||||
view := &ComponentView{
|
||||
LotName: c.LotName,
|
||||
Model: c.Model,
|
||||
PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3),
|
||||
PopularityScore: c.PopularityScore,
|
||||
Specs: c.Specs,
|
||||
}
|
||||
|
||||
if c.Lot != nil {
|
||||
view.Description = c.Lot.LotDescription
|
||||
}
|
||||
if c.Category != nil {
|
||||
view.Category = c.Category.Code
|
||||
view.CategoryName = c.Category.Name
|
||||
}
|
||||
|
||||
return view, nil
|
||||
}
|
||||
|
||||
func (s *ComponentService) GetCategories() ([]models.Category, error) {
|
||||
// If no database connection (offline mode), return default categories
|
||||
if s.categoryRepo == nil {
|
||||
return models.DefaultCategories, nil
|
||||
}
|
||||
return s.categoryRepo.GetAll()
|
||||
}
|
||||
|
||||
// ImportFromLot creates metadata entries for lots that don't have them
|
||||
func (s *ComponentService) ImportFromLot() (int, error) {
|
||||
// If no database connection (offline mode), return error
|
||||
if s.componentRepo == nil || s.categoryRepo == nil {
|
||||
return 0, fmt.Errorf("offline mode: import not available")
|
||||
}
|
||||
|
||||
lots, err := s.componentRepo.GetLotsWithoutMetadata()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
categories, err := s.categoryRepo.GetAll()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
categoryMap := make(map[string]uint)
|
||||
for _, cat := range categories {
|
||||
categoryMap[strings.ToUpper(cat.Code)] = cat.ID
|
||||
}
|
||||
|
||||
imported := 0
|
||||
for _, lot := range lots {
|
||||
// Use lot_category from database if available, otherwise parse from lot_name
|
||||
var category string
|
||||
if lot.LotCategory != nil && *lot.LotCategory != "" {
|
||||
category = strings.ToUpper(*lot.LotCategory)
|
||||
} else {
|
||||
category, _ = ParsePartNumber(lot.LotName)
|
||||
category = strings.ToUpper(category)
|
||||
}
|
||||
|
||||
_, model := ParsePartNumber(lot.LotName)
|
||||
|
||||
metadata := &models.LotMetadata{
|
||||
LotName: lot.LotName,
|
||||
Model: model,
|
||||
Specs: make(models.Specs),
|
||||
}
|
||||
|
||||
if catID, ok := categoryMap[category]; ok {
|
||||
metadata.CategoryID = &catID
|
||||
} else {
|
||||
// Create new category if it doesn't exist
|
||||
newCat, err := s.categoryRepo.CreateIfNotExists(category)
|
||||
if err == nil && newCat != nil {
|
||||
metadata.CategoryID = &newCat.ID
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.componentRepo.Create(metadata); err != nil {
|
||||
continue
|
||||
}
|
||||
imported++
|
||||
}
|
||||
|
||||
return imported, nil
|
||||
}
|
||||
|
||||
@@ -2,11 +2,8 @@ package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -14,37 +11,13 @@ var (
|
||||
ErrConfigForbidden = errors.New("access to configuration forbidden")
|
||||
)
|
||||
|
||||
// ConfigurationGetter is an interface for services that can retrieve configurations
|
||||
// Used by handlers to work with both ConfigurationService and LocalConfigurationService
|
||||
// ConfigurationGetter is an interface for services that can retrieve configurations.
|
||||
// Used by handlers to work with LocalConfigurationService.
|
||||
type ConfigurationGetter interface {
|
||||
GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error)
|
||||
GetByUUIDNoAuth(uuid string) (*models.Configuration, error)
|
||||
}
|
||||
|
||||
type ConfigurationService struct {
|
||||
configRepo *repository.ConfigurationRepository
|
||||
projectRepo *repository.ProjectRepository
|
||||
componentRepo *repository.ComponentRepository
|
||||
pricelistRepo *repository.PricelistRepository
|
||||
quoteService *QuoteService
|
||||
}
|
||||
|
||||
func NewConfigurationService(
|
||||
configRepo *repository.ConfigurationRepository,
|
||||
projectRepo *repository.ProjectRepository,
|
||||
componentRepo *repository.ComponentRepository,
|
||||
pricelistRepo *repository.PricelistRepository,
|
||||
quoteService *QuoteService,
|
||||
) *ConfigurationService {
|
||||
return &ConfigurationService{
|
||||
configRepo: configRepo,
|
||||
projectRepo: projectRepo,
|
||||
componentRepo: componentRepo,
|
||||
pricelistRepo: pricelistRepo,
|
||||
quoteService: quoteService,
|
||||
}
|
||||
}
|
||||
|
||||
type CreateConfigRequest struct {
|
||||
Name string `json:"name"`
|
||||
Items models.ConfigItems `json:"items"`
|
||||
@@ -70,583 +43,3 @@ type ArticlePreviewRequest struct {
|
||||
SupportCode string `json:"support_code,omitempty"`
|
||||
PricelistID *uint `json:"pricelist_id,omitempty"`
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
total := req.Items.Total()
|
||||
|
||||
// If server count is greater than 1, multiply the total by server count
|
||||
if req.ServerCount > 1 {
|
||||
total *= float64(req.ServerCount)
|
||||
}
|
||||
|
||||
config := &models.Configuration{
|
||||
UUID: uuid.New().String(),
|
||||
OwnerUsername: ownerUsername,
|
||||
ProjectUUID: projectUUID,
|
||||
Name: req.Name,
|
||||
Items: req.Items,
|
||||
TotalPrice: &total,
|
||||
CustomPrice: req.CustomPrice,
|
||||
Notes: req.Notes,
|
||||
IsTemplate: req.IsTemplate,
|
||||
ServerCount: req.ServerCount,
|
||||
ServerModel: req.ServerModel,
|
||||
SupportCode: req.SupportCode,
|
||||
Article: req.Article,
|
||||
PricelistID: pricelistID,
|
||||
WarehousePricelistID: req.WarehousePricelistID,
|
||||
CompetitorPricelistID: req.CompetitorPricelistID,
|
||||
ConfigType: req.ConfigType,
|
||||
DisablePriceRefresh: req.DisablePriceRefresh,
|
||||
OnlyInStock: req.OnlyInStock,
|
||||
}
|
||||
if config.ConfigType == "" {
|
||||
config.ConfigType = "server"
|
||||
}
|
||||
|
||||
if err := s.configRepo.Create(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error) {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
// Allow access if user owns config or it's a template
|
||||
if !s.isOwner(config, ownerUsername) && !config.IsTemplate {
|
||||
return nil, ErrConfigForbidden
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
if !s.isOwner(config, ownerUsername) {
|
||||
return nil, ErrConfigForbidden
|
||||
}
|
||||
|
||||
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
total := req.Items.Total()
|
||||
|
||||
// If server count is greater than 1, multiply the total by server count
|
||||
if req.ServerCount > 1 {
|
||||
total *= float64(req.ServerCount)
|
||||
}
|
||||
|
||||
config.Name = req.Name
|
||||
config.ProjectUUID = projectUUID
|
||||
config.Items = req.Items
|
||||
config.TotalPrice = &total
|
||||
config.CustomPrice = req.CustomPrice
|
||||
config.Notes = req.Notes
|
||||
config.IsTemplate = req.IsTemplate
|
||||
config.ServerCount = req.ServerCount
|
||||
config.ServerModel = req.ServerModel
|
||||
config.SupportCode = req.SupportCode
|
||||
config.Article = req.Article
|
||||
config.PricelistID = pricelistID
|
||||
config.WarehousePricelistID = req.WarehousePricelistID
|
||||
config.CompetitorPricelistID = req.CompetitorPricelistID
|
||||
config.DisablePriceRefresh = req.DisablePriceRefresh
|
||||
config.OnlyInStock = req.OnlyInStock
|
||||
|
||||
if err := s.configRepo.Update(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) Delete(uuid string, ownerUsername string) error {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
return ErrConfigNotFound
|
||||
}
|
||||
|
||||
if !s.isOwner(config, ownerUsername) {
|
||||
return ErrConfigForbidden
|
||||
}
|
||||
|
||||
return s.configRepo.Delete(config.ID)
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) Rename(uuid string, ownerUsername string, newName string) (*models.Configuration, error) {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
if !s.isOwner(config, ownerUsername) {
|
||||
return nil, ErrConfigForbidden
|
||||
}
|
||||
|
||||
config.Name = newName
|
||||
|
||||
if err := s.configRepo.Update(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) Clone(configUUID string, ownerUsername string, newName string) (*models.Configuration, error) {
|
||||
return s.CloneToProject(configUUID, ownerUsername, newName, nil)
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) CloneToProject(configUUID string, ownerUsername string, newName string, projectUUID *string) (*models.Configuration, error) {
|
||||
original, err := s.GetByUUID(configUUID, ownerUsername)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolvedProjectUUID := original.ProjectUUID
|
||||
if projectUUID != nil {
|
||||
resolvedProjectUUID, err = s.resolveProjectUUID(ownerUsername, projectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Create copy with new UUID and name
|
||||
total := original.Items.Total()
|
||||
|
||||
// If server count is greater than 1, multiply the total by server count
|
||||
if original.ServerCount > 1 {
|
||||
total *= float64(original.ServerCount)
|
||||
}
|
||||
|
||||
clone := &models.Configuration{
|
||||
UUID: uuid.New().String(),
|
||||
OwnerUsername: ownerUsername,
|
||||
ProjectUUID: resolvedProjectUUID,
|
||||
Name: newName,
|
||||
Items: original.Items,
|
||||
TotalPrice: &total,
|
||||
CustomPrice: original.CustomPrice,
|
||||
Notes: original.Notes,
|
||||
IsTemplate: false, // Clone is never a template
|
||||
ServerCount: original.ServerCount,
|
||||
ServerModel: original.ServerModel,
|
||||
SupportCode: original.SupportCode,
|
||||
Article: original.Article,
|
||||
PricelistID: original.PricelistID,
|
||||
WarehousePricelistID: original.WarehousePricelistID,
|
||||
CompetitorPricelistID: original.CompetitorPricelistID,
|
||||
DisablePriceRefresh: original.DisablePriceRefresh,
|
||||
OnlyInStock: original.OnlyInStock,
|
||||
}
|
||||
|
||||
if err := s.configRepo.Create(clone); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return clone, nil
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) ListByUser(ownerUsername string, page, perPage int) ([]models.Configuration, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 || perPage > 100 {
|
||||
perPage = 20
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
|
||||
return s.configRepo.ListByUser(ownerUsername, offset, perPage)
|
||||
}
|
||||
|
||||
// ListAll returns all configurations without user filter (for use when auth is disabled)
|
||||
func (s *ConfigurationService) ListAll(page, perPage int) ([]models.Configuration, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 || perPage > 100 {
|
||||
perPage = 20
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
|
||||
return s.configRepo.ListAll(offset, perPage)
|
||||
}
|
||||
|
||||
// GetByUUIDNoAuth returns configuration without ownership check (for use when auth is disabled)
|
||||
func (s *ConfigurationService) GetByUUIDNoAuth(uuid string) (*models.Configuration, error) {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// UpdateNoAuth updates configuration without ownership check
|
||||
func (s *ConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
projectUUID, err := s.resolveProjectUUID(config.OwnerUsername, req.ProjectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
total := req.Items.Total()
|
||||
if req.ServerCount > 1 {
|
||||
total *= float64(req.ServerCount)
|
||||
}
|
||||
|
||||
config.Name = req.Name
|
||||
config.ProjectUUID = projectUUID
|
||||
config.Items = req.Items
|
||||
config.TotalPrice = &total
|
||||
config.CustomPrice = req.CustomPrice
|
||||
config.Notes = req.Notes
|
||||
config.IsTemplate = req.IsTemplate
|
||||
config.ServerCount = req.ServerCount
|
||||
config.ServerModel = req.ServerModel
|
||||
config.SupportCode = req.SupportCode
|
||||
config.Article = req.Article
|
||||
config.PricelistID = pricelistID
|
||||
config.WarehousePricelistID = req.WarehousePricelistID
|
||||
config.CompetitorPricelistID = req.CompetitorPricelistID
|
||||
config.DisablePriceRefresh = req.DisablePriceRefresh
|
||||
config.OnlyInStock = req.OnlyInStock
|
||||
|
||||
if err := s.configRepo.Update(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// DeleteNoAuth deletes configuration without ownership check
|
||||
func (s *ConfigurationService) DeleteNoAuth(uuid string) error {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
return ErrConfigNotFound
|
||||
}
|
||||
return s.configRepo.Delete(config.ID)
|
||||
}
|
||||
|
||||
// RenameNoAuth renames configuration without ownership check
|
||||
func (s *ConfigurationService) RenameNoAuth(uuid string, newName string) (*models.Configuration, error) {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
config.Name = newName
|
||||
if err := s.configRepo.Update(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// CloneNoAuth clones configuration without ownership check
|
||||
func (s *ConfigurationService) CloneNoAuth(configUUID string, newName string, ownerUsername string) (*models.Configuration, error) {
|
||||
return s.CloneNoAuthToProject(configUUID, newName, ownerUsername, nil)
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) CloneNoAuthToProject(configUUID string, newName string, ownerUsername string, projectUUID *string) (*models.Configuration, error) {
|
||||
original, err := s.configRepo.GetByUUID(configUUID)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
resolvedProjectUUID := original.ProjectUUID
|
||||
if projectUUID != nil {
|
||||
resolvedProjectUUID, err = s.resolveProjectUUID(ownerUsername, projectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
total := original.Items.Total()
|
||||
if original.ServerCount > 1 {
|
||||
total *= float64(original.ServerCount)
|
||||
}
|
||||
|
||||
clone := &models.Configuration{
|
||||
UUID: uuid.New().String(),
|
||||
OwnerUsername: ownerUsername,
|
||||
ProjectUUID: resolvedProjectUUID,
|
||||
Name: newName,
|
||||
Items: original.Items,
|
||||
TotalPrice: &total,
|
||||
CustomPrice: original.CustomPrice,
|
||||
Notes: original.Notes,
|
||||
IsTemplate: false,
|
||||
ServerCount: original.ServerCount,
|
||||
PricelistID: original.PricelistID,
|
||||
OnlyInStock: original.OnlyInStock,
|
||||
}
|
||||
|
||||
if err := s.configRepo.Create(clone); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return clone, nil
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) resolveProjectUUID(ownerUsername string, projectUUID *string) (*string, error) {
|
||||
_ = ownerUsername
|
||||
if s.projectRepo == nil {
|
||||
return projectUUID, nil
|
||||
}
|
||||
if projectUUID == nil || *projectUUID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
project, err := s.projectRepo.GetByUUID(*projectUUID)
|
||||
if err != nil {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
if !project.IsActive {
|
||||
return nil, errors.New("project is archived")
|
||||
}
|
||||
|
||||
return &project.UUID, nil
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) resolvePricelistID(pricelistID *uint) (*uint, error) {
|
||||
if s.pricelistRepo == nil {
|
||||
return pricelistID, nil
|
||||
}
|
||||
if pricelistID != nil && *pricelistID > 0 {
|
||||
if _, err := s.pricelistRepo.GetByID(*pricelistID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pricelistID, nil
|
||||
}
|
||||
latest, err := s.pricelistRepo.GetLatestActive()
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &latest.ID, nil
|
||||
}
|
||||
|
||||
// RefreshPricesNoAuth refreshes prices without ownership check
|
||||
func (s *ConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
var latestPricelistID *uint
|
||||
if s.pricelistRepo != nil {
|
||||
if pl, err := s.pricelistRepo.GetLatestActive(); err == nil {
|
||||
latestPricelistID = &pl.ID
|
||||
}
|
||||
}
|
||||
|
||||
updatedItems := make(models.ConfigItems, len(config.Items))
|
||||
for i, item := range config.Items {
|
||||
if latestPricelistID != nil {
|
||||
if price, err := s.pricelistRepo.GetPriceForLot(*latestPricelistID, item.LotName); err == nil && price > 0 {
|
||||
updatedItems[i] = models.ConfigItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: price,
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if s.componentRepo == nil {
|
||||
updatedItems[i] = item
|
||||
continue
|
||||
}
|
||||
metadata, err := s.componentRepo.GetByLotName(item.LotName)
|
||||
if err != nil || metadata.CurrentPrice == nil {
|
||||
updatedItems[i] = item
|
||||
continue
|
||||
}
|
||||
|
||||
updatedItems[i] = models.ConfigItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: *metadata.CurrentPrice,
|
||||
}
|
||||
}
|
||||
|
||||
config.Items = updatedItems
|
||||
total := updatedItems.Total()
|
||||
if config.ServerCount > 1 {
|
||||
total *= float64(config.ServerCount)
|
||||
}
|
||||
|
||||
config.TotalPrice = &total
|
||||
if latestPricelistID != nil {
|
||||
config.PricelistID = latestPricelistID
|
||||
}
|
||||
now := time.Now()
|
||||
config.PriceUpdatedAt = &now
|
||||
|
||||
if err := s.configRepo.Update(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) ListTemplates(page, perPage int) ([]models.Configuration, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 || perPage > 100 {
|
||||
perPage = 20
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
|
||||
return s.configRepo.ListTemplates(offset, perPage)
|
||||
}
|
||||
|
||||
// RefreshPrices updates all component prices in the configuration with current prices
|
||||
func (s *ConfigurationService) RefreshPrices(uuid string, ownerUsername string) (*models.Configuration, error) {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
if !s.isOwner(config, ownerUsername) {
|
||||
return nil, ErrConfigForbidden
|
||||
}
|
||||
|
||||
var latestPricelistID *uint
|
||||
if s.pricelistRepo != nil {
|
||||
if pl, err := s.pricelistRepo.GetLatestActive(); err == nil {
|
||||
latestPricelistID = &pl.ID
|
||||
}
|
||||
}
|
||||
|
||||
// Update prices for all items
|
||||
updatedItems := make(models.ConfigItems, len(config.Items))
|
||||
for i, item := range config.Items {
|
||||
if latestPricelistID != nil {
|
||||
if price, err := s.pricelistRepo.GetPriceForLot(*latestPricelistID, item.LotName); err == nil && price > 0 {
|
||||
updatedItems[i] = models.ConfigItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: price,
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Get current component price
|
||||
if s.componentRepo == nil {
|
||||
updatedItems[i] = item
|
||||
continue
|
||||
}
|
||||
metadata, err := s.componentRepo.GetByLotName(item.LotName)
|
||||
if err != nil || metadata.CurrentPrice == nil {
|
||||
// Keep original item if component not found or no price available
|
||||
updatedItems[i] = item
|
||||
continue
|
||||
}
|
||||
|
||||
// Update item with current price
|
||||
updatedItems[i] = models.ConfigItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: *metadata.CurrentPrice,
|
||||
}
|
||||
}
|
||||
|
||||
// Update configuration
|
||||
config.Items = updatedItems
|
||||
total := updatedItems.Total()
|
||||
|
||||
// If server count is greater than 1, multiply the total by server count
|
||||
if config.ServerCount > 1 {
|
||||
total *= float64(config.ServerCount)
|
||||
}
|
||||
|
||||
config.TotalPrice = &total
|
||||
if latestPricelistID != nil {
|
||||
config.PricelistID = latestPricelistID
|
||||
}
|
||||
|
||||
// Set price update timestamp
|
||||
now := time.Now()
|
||||
config.PriceUpdatedAt = &now
|
||||
|
||||
if err := s.configRepo.Update(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) isOwner(config *models.Configuration, ownerUsername string) bool {
|
||||
if config == nil || ownerUsername == "" {
|
||||
return false
|
||||
}
|
||||
return config.OwnerUsername == ownerUsername
|
||||
}
|
||||
|
||||
// // Export configuration as JSON
|
||||
// type ConfigExport struct {
|
||||
// Name string `json:"name"`
|
||||
// Notes string `json:"notes"`
|
||||
// Items models.ConfigItems `json:"items"`
|
||||
// }
|
||||
//
|
||||
// func (s *ConfigurationService) ExportJSON(uuid string, userID uint) ([]byte, error) {
|
||||
// config, err := s.GetByUUID(uuid, userID)
|
||||
// if err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
//
|
||||
// export := ConfigExport{
|
||||
// Name: config.Name,
|
||||
// Notes: config.Notes,
|
||||
// Items: config.Items,
|
||||
// }
|
||||
//
|
||||
// return json.MarshalIndent(export, "", " ")
|
||||
// }
|
||||
//
|
||||
// func (s *ConfigurationService) ImportJSON(userID uint, data []byte) (*models.Configuration, error) {
|
||||
// var export ConfigExport
|
||||
// if err := json.Unmarshal(data, &export); err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
//
|
||||
// req := &CreateConfigRequest{
|
||||
// Name: export.Name,
|
||||
// Notes: export.Notes,
|
||||
// Items: export.Items,
|
||||
// }
|
||||
//
|
||||
// return s.Create(userID, req)
|
||||
// }
|
||||
|
||||
@@ -18,32 +18,22 @@ var (
|
||||
)
|
||||
|
||||
type QuoteService struct {
|
||||
componentRepo *repository.ComponentRepository
|
||||
pricelistRepo *repository.PricelistRepository
|
||||
localDB *localdb.LocalDB
|
||||
pricingService priceResolver
|
||||
cacheMu sync.RWMutex
|
||||
priceCache map[string]cachedLotPrice
|
||||
cacheTTL time.Duration
|
||||
}
|
||||
|
||||
type priceResolver interface {
|
||||
GetEffectivePrice(lotName string) (*float64, error)
|
||||
pricelistRepo *repository.PricelistRepository
|
||||
localDB *localdb.LocalDB
|
||||
cacheMu sync.RWMutex
|
||||
priceCache map[string]cachedLotPrice
|
||||
cacheTTL time.Duration
|
||||
}
|
||||
|
||||
func NewQuoteService(
|
||||
componentRepo *repository.ComponentRepository,
|
||||
pricelistRepo *repository.PricelistRepository,
|
||||
localDB *localdb.LocalDB,
|
||||
pricingService priceResolver,
|
||||
) *QuoteService {
|
||||
return &QuoteService{
|
||||
componentRepo: componentRepo,
|
||||
pricelistRepo: pricelistRepo,
|
||||
localDB: localDB,
|
||||
pricingService: pricingService,
|
||||
priceCache: make(map[string]cachedLotPrice, 4096),
|
||||
cacheTTL: 10 * time.Second,
|
||||
pricelistRepo: pricelistRepo,
|
||||
localDB: localDB,
|
||||
priceCache: make(map[string]cachedLotPrice, 4096),
|
||||
cacheTTL: 10 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,73 +165,7 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
|
||||
return result, nil
|
||||
}
|
||||
|
||||
if s.componentRepo == nil || s.pricingService == nil {
|
||||
return nil, errors.New("quote calculation not available")
|
||||
}
|
||||
|
||||
result := &QuoteValidationResult{
|
||||
Valid: true,
|
||||
Items: make([]QuoteItem, 0, len(req.Items)),
|
||||
Errors: make([]string, 0),
|
||||
Warnings: make([]string, 0),
|
||||
}
|
||||
|
||||
lotNames := make([]string, len(req.Items))
|
||||
quantities := make(map[string]int)
|
||||
for i, item := range req.Items {
|
||||
lotNames[i] = item.LotName
|
||||
quantities[item.LotName] = item.Quantity
|
||||
}
|
||||
|
||||
components, err := s.componentRepo.GetMultiple(lotNames)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
componentMap := make(map[string]*models.LotMetadata)
|
||||
for i := range components {
|
||||
componentMap[components[i].LotName] = &components[i]
|
||||
}
|
||||
|
||||
var total float64
|
||||
|
||||
for _, reqItem := range req.Items {
|
||||
comp, exists := componentMap[reqItem.LotName]
|
||||
if !exists {
|
||||
result.Valid = false
|
||||
result.Errors = append(result.Errors, "Component not found: "+reqItem.LotName)
|
||||
continue
|
||||
}
|
||||
|
||||
item := QuoteItem{
|
||||
LotName: reqItem.LotName,
|
||||
Quantity: reqItem.Quantity,
|
||||
HasPrice: false,
|
||||
}
|
||||
|
||||
if comp.Lot != nil {
|
||||
item.Description = comp.Lot.LotDescription
|
||||
}
|
||||
if comp.Category != nil {
|
||||
item.Category = comp.Category.Code
|
||||
}
|
||||
|
||||
// Get effective price (override or calculated)
|
||||
price, err := s.pricingService.GetEffectivePrice(reqItem.LotName)
|
||||
if err == nil && price != nil && *price > 0 {
|
||||
item.UnitPrice = *price
|
||||
item.TotalPrice = *price * float64(reqItem.Quantity)
|
||||
item.HasPrice = true
|
||||
total += item.TotalPrice
|
||||
} else {
|
||||
result.Warnings = append(result.Warnings, "No price available for: "+reqItem.LotName)
|
||||
}
|
||||
|
||||
result.Items = append(result.Items, item)
|
||||
}
|
||||
|
||||
result.Total = total
|
||||
return result, nil
|
||||
return nil, errors.New("quote calculation requires local database")
|
||||
}
|
||||
|
||||
func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLevelsResult, error) {
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
func TestCalculatePriceLevels_WithMissingLevel(t *testing.T) {
|
||||
db := newPriceLevelsTestDB(t)
|
||||
repo := repository.NewPricelistRepository(db)
|
||||
service := NewQuoteService(nil, repo, nil, nil)
|
||||
service := NewQuoteService(repo, nil)
|
||||
|
||||
estimate := seedPricelistWithItem(t, repo, "estimate", "CPU_X", 100)
|
||||
_ = estimate
|
||||
@@ -57,7 +57,7 @@ func TestCalculatePriceLevels_WithMissingLevel(t *testing.T) {
|
||||
func TestCalculatePriceLevels_UsesExplicitPricelistIDs(t *testing.T) {
|
||||
db := newPriceLevelsTestDB(t)
|
||||
repo := repository.NewPricelistRepository(db)
|
||||
service := NewQuoteService(nil, repo, nil, nil)
|
||||
service := NewQuoteService(repo, nil)
|
||||
|
||||
olderEstimate := seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 80)
|
||||
seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 90)
|
||||
|
||||
Reference in New Issue
Block a user