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:
Mikhail Chusavitin
2026-06-28 21:21:34 +03:00
parent 687ab99d85
commit cc72052c8a
11 changed files with 25 additions and 1554 deletions

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}