## Overview Removed the CurrentPrice and SyncedAt fields from local_components, transitioning to a pricelist-based pricing model where all prices are sourced from local_pricelist_items based on the configuration's selected pricelist. ## Changes ### Data Model Updates - **LocalComponent**: Now stores only metadata (LotName, LotDescription, Category, Model) - Removed: CurrentPrice, SyncedAt (both redundant) - Pricing is now exclusively sourced from local_pricelist_items - **LocalConfiguration**: Added pricelist selection fields - Added: WarehousePricelistID, CompetitorPricelistID - These complement the existing PricelistID (Estimate) ### Migrations - Added migration "drop_component_unused_fields" to remove CurrentPrice and SyncedAt columns - Added migration "add_warehouse_competitor_pricelists" to add new pricelist fields ### Component Sync - Removed current_price from MariaDB query - Removed CurrentPrice assignment in component creation - SyncComponentPrices now exclusively updates based on pricelist_items via quote calculation ### Quote Calculation - Added PricelistID field to QuoteRequest - Updated local-first path to use pricelist_items instead of component.CurrentPrice - Falls back to latest estimate pricelist if PricelistID not specified - Maintains offline-first behavior: local queries work without MariaDB ### Configuration Refresh - Removed fallback on component.CurrentPrice - Prices are only refreshed from local_pricelist_items - If price not found in pricelist, original price is preserved ### API Changes - Removed CurrentPrice from ComponentView - Components API no longer returns pricing information - Pricing is accessed via QuoteService or PricelistService ### Code Cleanup - Removed UpdateComponentPricesFromPricelist() method - Removed EnsureComponentPricesFromPricelists() method - Updated UnifiedRepository to remove offline pricing logic - Updated converters to remove CurrentPrice mapping ## Architecture Impact - Components = metadata store only - Prices = managed by pricelist system - Quote calculation = owns all pricing logic - Local-first behavior preserved: SQLite queries work offline, no MariaDB dependency ## Testing - Build successful - All code compiles without errors - Ready for migration testing with existing databases Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
218 lines
5.5 KiB
Go
218 lines
5.5 KiB
Go
package services
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
|
)
|
|
|
|
type ComponentService struct {
|
|
componentRepo *repository.ComponentRepository
|
|
categoryRepo *repository.CategoryRepository
|
|
statsRepo *repository.StatsRepository
|
|
}
|
|
|
|
func NewComponentService(
|
|
componentRepo *repository.ComponentRepository,
|
|
categoryRepo *repository.CategoryRepository,
|
|
statsRepo *repository.StatsRepository,
|
|
) *ComponentService {
|
|
return &ComponentService{
|
|
componentRepo: componentRepo,
|
|
categoryRepo: categoryRepo,
|
|
statsRepo: statsRepo,
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
Components []ComponentView `json:"components"`
|
|
Total int64 `json:"total"`
|
|
Page int `json:"page"`
|
|
PerPage int `json:"per_page"`
|
|
}
|
|
|
|
type ComponentView struct {
|
|
LotName string `json:"lot_name"`
|
|
Description string `json:"description"`
|
|
Category string `json:"category"`
|
|
CategoryName string `json:"category_name"`
|
|
Model string `json:"model"`
|
|
PriceFreshness models.PriceFreshness `json:"price_freshness"`
|
|
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{
|
|
Components: []ComponentView{},
|
|
Total: 0,
|
|
Page: page,
|
|
PerPage: perPage,
|
|
}, 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
|
|
}
|
|
|
|
return &ComponentListResult{
|
|
Components: views,
|
|
Total: total,
|
|
Page: page,
|
|
PerPage: perPage,
|
|
}, 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
|
|
_ = s.componentRepo.IncrementRequestCount(lotName)
|
|
|
|
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
|
|
}
|