Go refactoring: - Split handlers/pricing.go (2446→291 lines) into 5 focused files - Split services/stock_import.go (1334→~400 lines) into stock_mappings.go + stock_parse.go - Split services/sync/service.go (1290→~250 lines) into 3 files JS extraction: - Move all inline <script> blocks to web/static/js/ (6 files) - Templates reduced: admin_pricing 2873→521, lot 1531→304, vendor_mappings 1063→169, etc. Competitor pricelists (migrations 033-039): - qt_competitors + partnumber_log_competitors tables - Excel import with column mapping, dedup, bulk insert - p/n→lot resolution via weighted_median, discount applied - Unmapped p/ns written to qt_vendor_partnumber_seen - Quote counts (unique/total) shown on /admin/competitors - price_method="weighted_median", price_period_days=0 stored explicitly Fix price_method/price_period_days for warehouse items: - warehouse: weighted_avg, period=0 - competitor: weighted_median, period=0 - Removes misleading DB defaults (was: median/90) Update bible: architecture.md, pricelist.md, history.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
214 lines
6.8 KiB
Go
214 lines
6.8 KiB
Go
package sync
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"git.mchus.pro/mchus/priceforge/internal/localdb"
|
|
"git.mchus.pro/mchus/priceforge/internal/models"
|
|
"git.mchus.pro/mchus/priceforge/internal/repository"
|
|
)
|
|
|
|
// SyncPricelists synchronizes all active pricelists from server to local SQLite
|
|
func (s *Service) SyncPricelists() (int, error) {
|
|
slog.Info("starting pricelist sync")
|
|
if _, err := s.EnsureReadinessForSync(); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
// Get database connection
|
|
mariaDB, err := s.getDB()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("database not available: %w", err)
|
|
}
|
|
|
|
// Create repository
|
|
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
|
|
|
// Get active pricelists from server (up to 100)
|
|
serverPricelists, _, err := pricelistRepo.ListActive(0, 100)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("getting active server pricelists: %w", err)
|
|
}
|
|
|
|
synced := 0
|
|
var latestEstimateLocalID uint
|
|
var latestEstimateCreatedAt time.Time
|
|
for _, pl := range serverPricelists {
|
|
// Check if pricelist already exists locally
|
|
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
|
|
if existing != nil {
|
|
// Track latest estimate pricelist by created_at for component refresh.
|
|
if pl.Source == string(models.PricelistSourceEstimate) && (latestEstimateCreatedAt.IsZero() || pl.CreatedAt.After(latestEstimateCreatedAt)) {
|
|
latestEstimateCreatedAt = pl.CreatedAt
|
|
latestEstimateLocalID = existing.ID
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Create local pricelist
|
|
localPL := &localdb.LocalPricelist{
|
|
ServerID: pl.ID,
|
|
Source: pl.Source,
|
|
Version: pl.Version,
|
|
Name: pl.Notification, // Using notification as name
|
|
CreatedAt: pl.CreatedAt,
|
|
SyncedAt: time.Now(),
|
|
IsUsed: false,
|
|
}
|
|
|
|
if err := s.localDB.SaveLocalPricelist(localPL); err != nil {
|
|
slog.Warn("failed to save local pricelist", "version", pl.Version, "error", err)
|
|
continue
|
|
}
|
|
|
|
// Sync items for the newly created pricelist
|
|
itemCount, err := s.SyncPricelistItems(localPL.ID)
|
|
if err != nil {
|
|
slog.Warn("failed to sync pricelist items", "version", pl.Version, "error", err)
|
|
// Continue even if items sync fails - we have the pricelist metadata
|
|
} else {
|
|
slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount)
|
|
}
|
|
|
|
if pl.Source == string(models.PricelistSourceEstimate) && (latestEstimateCreatedAt.IsZero() || pl.CreatedAt.After(latestEstimateCreatedAt)) {
|
|
latestEstimateCreatedAt = pl.CreatedAt
|
|
latestEstimateLocalID = localPL.ID
|
|
}
|
|
synced++
|
|
}
|
|
|
|
// Update component prices from latest estimate pricelist only.
|
|
if latestEstimateLocalID > 0 {
|
|
updated, err := s.localDB.UpdateComponentPricesFromPricelist(latestEstimateLocalID)
|
|
if err != nil {
|
|
slog.Warn("failed to update component prices from pricelist", "error", err)
|
|
} else {
|
|
slog.Info("updated component prices from latest pricelist", "updated", updated)
|
|
}
|
|
}
|
|
|
|
// Update last sync time
|
|
s.localDB.SetLastSyncTime(time.Now())
|
|
s.RecordSyncHeartbeat()
|
|
|
|
slog.Info("pricelist sync completed", "synced", synced, "total", len(serverPricelists))
|
|
return synced, nil
|
|
}
|
|
|
|
// SyncPricelistItems synchronizes items for a specific pricelist
|
|
func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
|
|
// Get local pricelist
|
|
localPL, err := s.localDB.GetLocalPricelistByID(localPricelistID)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("getting local pricelist: %w", err)
|
|
}
|
|
|
|
// Check if items already exist
|
|
existingCount := s.localDB.CountLocalPricelistItems(localPricelistID)
|
|
if existingCount > 0 {
|
|
slog.Debug("pricelist items already synced", "pricelist_id", localPricelistID, "count", existingCount)
|
|
return int(existingCount), nil
|
|
}
|
|
|
|
// Get database connection
|
|
mariaDB, err := s.getDB()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("database not available: %w", err)
|
|
}
|
|
|
|
// Create repository
|
|
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
|
|
|
// Get items from server
|
|
serverItems, _, err := pricelistRepo.GetItems(localPL.ServerID, 0, 10000, "")
|
|
if err != nil {
|
|
return 0, fmt.Errorf("getting server pricelist items: %w", err)
|
|
}
|
|
|
|
// Convert and save locally
|
|
localItems := make([]localdb.LocalPricelistItem, len(serverItems))
|
|
for i, item := range serverItems {
|
|
partnumbers := make(localdb.LocalStringList, 0, len(item.Partnumbers))
|
|
partnumbers = append(partnumbers, item.Partnumbers...)
|
|
localItems[i] = localdb.LocalPricelistItem{
|
|
PricelistID: localPricelistID,
|
|
LotName: item.LotName,
|
|
Price: item.Price,
|
|
AvailableQty: item.AvailableQty,
|
|
Partnumbers: partnumbers,
|
|
}
|
|
}
|
|
|
|
if err := s.localDB.SaveLocalPricelistItems(localItems); err != nil {
|
|
return 0, fmt.Errorf("saving local pricelist items: %w", err)
|
|
}
|
|
|
|
slog.Info("synced pricelist items", "pricelist_id", localPricelistID, "items", len(localItems))
|
|
return len(localItems), nil
|
|
}
|
|
|
|
// SyncPricelistItemsByServerID syncs items for a pricelist by its server ID
|
|
func (s *Service) SyncPricelistItemsByServerID(serverPricelistID uint) (int, error) {
|
|
localPL, err := s.localDB.GetLocalPricelistByServerID(serverPricelistID)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("local pricelist not found for server ID %d", serverPricelistID)
|
|
}
|
|
return s.SyncPricelistItems(localPL.ID)
|
|
}
|
|
|
|
// GetLocalPriceForLot returns the price for a lot from a local pricelist
|
|
func (s *Service) GetLocalPriceForLot(localPricelistID uint, lotName string) (float64, error) {
|
|
return s.localDB.GetLocalPriceForLot(localPricelistID, lotName)
|
|
}
|
|
|
|
// GetPricelistForOffline returns a pricelist suitable for offline use
|
|
// If items are not synced, it will sync them first
|
|
func (s *Service) GetPricelistForOffline(serverPricelistID uint) (*localdb.LocalPricelist, error) {
|
|
// Ensure pricelist is synced
|
|
localPL, err := s.localDB.GetLocalPricelistByServerID(serverPricelistID)
|
|
if err != nil {
|
|
// Try to sync pricelists first
|
|
if _, err := s.SyncPricelists(); err != nil {
|
|
return nil, fmt.Errorf("syncing pricelists: %w", err)
|
|
}
|
|
|
|
// Try again
|
|
localPL, err = s.localDB.GetLocalPricelistByServerID(serverPricelistID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pricelist not found on server: %w", err)
|
|
}
|
|
}
|
|
|
|
// Ensure items are synced
|
|
if _, err := s.SyncPricelistItems(localPL.ID); err != nil {
|
|
return nil, fmt.Errorf("syncing pricelist items: %w", err)
|
|
}
|
|
|
|
return localPL, nil
|
|
}
|
|
|
|
// SyncPricelistsIfNeeded checks for new pricelists and syncs if needed
|
|
// This should be called before creating a new configuration when online
|
|
func (s *Service) SyncPricelistsIfNeeded() error {
|
|
needSync, err := s.NeedSync()
|
|
if err != nil {
|
|
slog.Warn("failed to check if sync needed", "error", err)
|
|
return nil // Don't fail on check error
|
|
}
|
|
|
|
if !needSync {
|
|
slog.Debug("pricelists are up to date, no sync needed")
|
|
return nil
|
|
}
|
|
|
|
slog.Info("new pricelists detected, syncing...")
|
|
_, err = s.SyncPricelists()
|
|
if err != nil {
|
|
return fmt.Errorf("syncing pricelists: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|