## 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>
394 lines
11 KiB
Go
394 lines
11 KiB
Go
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
|
|
}
|