Optimize task retention from 5 minutes to 30 seconds to reduce polling overhead since toast notifications are shown only once. Add conditional warehouse pricelist creation via checkbox. Fix category storage in warehouse pricelists to properly load from lot table. Replace SSE with task polling for all long operations. Add comprehensive logging for debugging while minimizing noise from polling endpoints. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
540 lines
17 KiB
Go
540 lines
17 KiB
Go
package repository
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.mchus.pro/mchus/priceforge/internal/dbutil"
|
|
"git.mchus.pro/mchus/priceforge/internal/models"
|
|
"git.mchus.pro/mchus/priceforge/internal/warehouse"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type PricelistRepository struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func NewPricelistRepository(db *gorm.DB) *PricelistRepository {
|
|
return &PricelistRepository{db: db}
|
|
}
|
|
|
|
// List returns pricelists with pagination
|
|
func (r *PricelistRepository) List(offset, limit int) ([]models.PricelistSummary, int64, error) {
|
|
return r.ListBySource("", offset, limit)
|
|
}
|
|
|
|
// ListBySource returns pricelists filtered by source when provided.
|
|
func (r *PricelistRepository) ListBySource(source string, offset, limit int) ([]models.PricelistSummary, int64, error) {
|
|
query := r.db.Model(&models.Pricelist{}).
|
|
Where("EXISTS (SELECT 1 FROM qt_pricelist_items WHERE qt_pricelist_items.pricelist_id = qt_pricelists.id)")
|
|
if source != "" {
|
|
query = query.Where("source = ?", source)
|
|
}
|
|
|
|
var total int64
|
|
if err := query.Count(&total).Error; err != nil {
|
|
return nil, 0, fmt.Errorf("counting pricelists: %w", err)
|
|
}
|
|
|
|
var pricelists []models.Pricelist
|
|
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
|
|
return nil, 0, fmt.Errorf("listing pricelists: %w", err)
|
|
}
|
|
|
|
return r.toSummaries(pricelists), total, nil
|
|
}
|
|
|
|
// ListActive returns active pricelists with pagination.
|
|
func (r *PricelistRepository) ListActive(offset, limit int) ([]models.PricelistSummary, int64, error) {
|
|
return r.ListActiveBySource("", offset, limit)
|
|
}
|
|
|
|
// ListActiveBySource returns active pricelists filtered by source when provided.
|
|
func (r *PricelistRepository) ListActiveBySource(source string, offset, limit int) ([]models.PricelistSummary, int64, error) {
|
|
query := r.db.Model(&models.Pricelist{}).
|
|
Where("is_active = ?", true).
|
|
Where("EXISTS (SELECT 1 FROM qt_pricelist_items WHERE qt_pricelist_items.pricelist_id = qt_pricelists.id)")
|
|
if source != "" {
|
|
query = query.Where("source = ?", source)
|
|
}
|
|
|
|
var total int64
|
|
if err := query.Count(&total).Error; err != nil {
|
|
return nil, 0, fmt.Errorf("counting active pricelists: %w", err)
|
|
}
|
|
|
|
var pricelists []models.Pricelist
|
|
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
|
|
return nil, 0, fmt.Errorf("listing active pricelists: %w", err)
|
|
}
|
|
|
|
return r.toSummaries(pricelists), total, nil
|
|
}
|
|
|
|
// CountActive returns the number of active pricelists.
|
|
func (r *PricelistRepository) CountActive() (int64, error) {
|
|
var total int64
|
|
if err := r.db.Model(&models.Pricelist{}).Where("is_active = ?", true).Count(&total).Error; err != nil {
|
|
return 0, fmt.Errorf("counting active pricelists: %w", err)
|
|
}
|
|
return total, nil
|
|
}
|
|
|
|
func (r *PricelistRepository) toSummaries(pricelists []models.Pricelist) []models.PricelistSummary {
|
|
// Get item counts for each pricelist
|
|
summaries := make([]models.PricelistSummary, len(pricelists))
|
|
for i, pl := range pricelists {
|
|
var itemCount int64
|
|
r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", pl.ID).Count(&itemCount)
|
|
usageCount, _ := r.CountUsage(pl.ID)
|
|
|
|
summaries[i] = models.PricelistSummary{
|
|
ID: pl.ID,
|
|
Source: pl.Source,
|
|
Version: pl.Version,
|
|
Notification: pl.Notification,
|
|
CreatedAt: pl.CreatedAt,
|
|
CreatedBy: pl.CreatedBy,
|
|
IsActive: pl.IsActive,
|
|
UsageCount: int(usageCount),
|
|
ExpiresAt: pl.ExpiresAt,
|
|
ItemCount: itemCount,
|
|
}
|
|
}
|
|
|
|
return summaries
|
|
}
|
|
|
|
// GetByID returns a pricelist by ID
|
|
func (r *PricelistRepository) GetByID(id uint) (*models.Pricelist, error) {
|
|
var pricelist models.Pricelist
|
|
if err := r.db.First(&pricelist, id).Error; err != nil {
|
|
return nil, fmt.Errorf("getting pricelist: %w", err)
|
|
}
|
|
|
|
// Get item count
|
|
var itemCount int64
|
|
r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", id).Count(&itemCount)
|
|
pricelist.ItemCount = int(itemCount)
|
|
if usageCount, err := r.CountUsage(id); err == nil {
|
|
pricelist.UsageCount = int(usageCount)
|
|
}
|
|
|
|
return &pricelist, nil
|
|
}
|
|
|
|
// GetByVersion returns a pricelist by version string
|
|
func (r *PricelistRepository) GetByVersion(version string) (*models.Pricelist, error) {
|
|
return r.GetBySourceAndVersion(string(models.PricelistSourceEstimate), version)
|
|
}
|
|
|
|
// GetBySourceAndVersion returns a pricelist by source/version.
|
|
func (r *PricelistRepository) GetBySourceAndVersion(source, version string) (*models.Pricelist, error) {
|
|
var pricelist models.Pricelist
|
|
if err := r.db.Where("source = ? AND version = ?", source, version).First(&pricelist).Error; err != nil {
|
|
return nil, fmt.Errorf("getting pricelist by version: %w", err)
|
|
}
|
|
return &pricelist, nil
|
|
}
|
|
|
|
// GetLatestActive returns the most recent active pricelist
|
|
func (r *PricelistRepository) GetLatestActive() (*models.Pricelist, error) {
|
|
return r.GetLatestActiveBySource(string(models.PricelistSourceEstimate))
|
|
}
|
|
|
|
// GetLatestActiveBySource returns the most recent active pricelist by source.
|
|
func (r *PricelistRepository) GetLatestActiveBySource(source string) (*models.Pricelist, error) {
|
|
var pricelist models.Pricelist
|
|
if err := r.db.Where("is_active = ? AND source = ?", true, source).Order("created_at DESC").First(&pricelist).Error; err != nil {
|
|
return nil, fmt.Errorf("getting latest pricelist: %w", err)
|
|
}
|
|
return &pricelist, nil
|
|
}
|
|
|
|
// Create creates a new pricelist
|
|
func (r *PricelistRepository) Create(pricelist *models.Pricelist) error {
|
|
if err := r.db.Create(pricelist).Error; err != nil {
|
|
return fmt.Errorf("creating pricelist: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Update updates a pricelist
|
|
func (r *PricelistRepository) Update(pricelist *models.Pricelist) error {
|
|
if err := r.db.Save(pricelist).Error; err != nil {
|
|
return fmt.Errorf("updating pricelist: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Delete deletes a pricelist if usage_count is 0
|
|
func (r *PricelistRepository) Delete(id uint) error {
|
|
usageCount, err := r.CountUsage(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if usageCount > 0 {
|
|
return fmt.Errorf("cannot delete pricelist with usage_count > 0 (current: %d)", usageCount)
|
|
}
|
|
|
|
// Delete items first
|
|
if err := r.db.Where("pricelist_id = ?", id).Delete(&models.PricelistItem{}).Error; err != nil {
|
|
return fmt.Errorf("deleting pricelist items: %w", err)
|
|
}
|
|
|
|
// Delete pricelist
|
|
if err := r.db.Delete(&models.Pricelist{}, id).Error; err != nil {
|
|
return fmt.Errorf("deleting pricelist: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CreateItems batch inserts pricelist items
|
|
func (r *PricelistRepository) CreateItems(items []models.PricelistItem) error {
|
|
if len(items) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Use batch insert for better performance
|
|
batchSize := 500
|
|
query := dbutil.WithTimeout(r.db, 30*time.Second) // Timeout per batch
|
|
for i := 0; i < len(items); i += batchSize {
|
|
end := i + batchSize
|
|
if end > len(items) {
|
|
end = len(items)
|
|
}
|
|
batch := items[i:end]
|
|
if err := query.Execute(func(db *gorm.DB) error {
|
|
return db.CreateInBatches(batch, batchSize).Error
|
|
}); err != nil {
|
|
return fmt.Errorf("batch inserting pricelist items: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetItems returns pricelist items with pagination
|
|
func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, search string) ([]models.PricelistItem, int64, error) {
|
|
var total int64
|
|
query := r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", pricelistID)
|
|
|
|
if search != "" {
|
|
query = query.Where("lot_name LIKE ?", "%"+search+"%")
|
|
}
|
|
|
|
if err := query.Count(&total).Error; err != nil {
|
|
return nil, 0, fmt.Errorf("counting pricelist items: %w", err)
|
|
}
|
|
|
|
var items []models.PricelistItem
|
|
// Optimized query with JOIN to avoid N+1
|
|
// lot_category is stored in pricelist_items, lot_description comes from JOIN
|
|
itemsQuery := r.db.Table("qt_pricelist_items AS pi").
|
|
Select("pi.*, COALESCE(l.lot_description, '') AS lot_description").
|
|
Joins("LEFT JOIN lot AS l ON l.lot_name = pi.lot_name").
|
|
Where("pi.pricelist_id = ?", pricelistID)
|
|
|
|
if search != "" {
|
|
itemsQuery = itemsQuery.Where("pi.lot_name LIKE ?", "%"+search+"%")
|
|
}
|
|
|
|
if err := itemsQuery.Order("pi.lot_name").Offset(offset).Limit(limit).Scan(&items).Error; err != nil {
|
|
return nil, 0, fmt.Errorf("listing pricelist items: %w", err)
|
|
}
|
|
|
|
var pl models.Pricelist
|
|
if err := r.db.Select("source").Where("id = ?", pricelistID).First(&pl).Error; err == nil && pl.Source == string(models.PricelistSourceWarehouse) {
|
|
if err := r.enrichWarehouseItems(items); err != nil {
|
|
return nil, 0, fmt.Errorf("enriching warehouse items: %w", err)
|
|
}
|
|
}
|
|
|
|
return items, total, nil
|
|
}
|
|
|
|
// GetLotNames returns distinct lot names from pricelist items.
|
|
func (r *PricelistRepository) GetLotNames(pricelistID uint) ([]string, error) {
|
|
var lotNames []string
|
|
if err := r.db.Model(&models.PricelistItem{}).
|
|
Where("pricelist_id = ?", pricelistID).
|
|
Distinct("lot_name").
|
|
Order("lot_name ASC").
|
|
Pluck("lot_name", &lotNames).Error; err != nil {
|
|
return nil, fmt.Errorf("listing pricelist lot names: %w", err)
|
|
}
|
|
return lotNames, nil
|
|
}
|
|
|
|
func (r *PricelistRepository) enrichWarehouseItems(items []models.PricelistItem) error {
|
|
if len(items) == 0 {
|
|
return nil
|
|
}
|
|
lots := make([]string, 0, len(items))
|
|
seen := make(map[string]struct{}, len(items))
|
|
for _, item := range items {
|
|
lot := strings.TrimSpace(item.LotName)
|
|
if lot == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[lot]; ok {
|
|
continue
|
|
}
|
|
seen[lot] = struct{}{}
|
|
lots = append(lots, lot)
|
|
}
|
|
if len(lots) == 0 {
|
|
return nil
|
|
}
|
|
|
|
qtyByLot, partnumbersByLot, err := warehouse.LoadLotMetrics(r.db, lots, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for i := range items {
|
|
if qty, ok := qtyByLot[items[i].LotName]; ok {
|
|
q := qty
|
|
items[i].AvailableQty = &q
|
|
}
|
|
items[i].Partnumbers = partnumbersByLot[items[i].LotName]
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetPriceForLot returns item price for a lot within a pricelist.
|
|
func (r *PricelistRepository) GetPriceForLot(pricelistID uint, lotName string) (float64, error) {
|
|
var item models.PricelistItem
|
|
if err := r.db.Where("pricelist_id = ? AND lot_name = ?", pricelistID, lotName).First(&item).Error; err != nil {
|
|
return 0, err
|
|
}
|
|
return item.Price, nil
|
|
}
|
|
|
|
// GetPricesForLots returns price map for given lots within a pricelist.
|
|
func (r *PricelistRepository) GetPricesForLots(pricelistID uint, lotNames []string) (map[string]float64, error) {
|
|
result := make(map[string]float64, len(lotNames))
|
|
if pricelistID == 0 || len(lotNames) == 0 {
|
|
return result, nil
|
|
}
|
|
|
|
var rows []models.PricelistItem
|
|
if err := r.db.Select("lot_name, price").
|
|
Where("pricelist_id = ? AND lot_name IN ?", pricelistID, lotNames).
|
|
Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, row := range rows {
|
|
if row.Price > 0 {
|
|
result[row.LotName] = row.Price
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// SetActive toggles active flag on a pricelist.
|
|
func (r *PricelistRepository) SetActive(id uint, isActive bool) error {
|
|
return r.db.Model(&models.Pricelist{}).Where("id = ?", id).Update("is_active", isActive).Error
|
|
}
|
|
|
|
// GenerateVersion generates a new version string in format YYYY-MM-DD-NNN
|
|
func (r *PricelistRepository) GenerateVersion() (string, error) {
|
|
return r.GenerateVersionBySource(string(models.PricelistSourceEstimate))
|
|
}
|
|
|
|
// GenerateVersionBySource generates a new version string in format YYYY-MM-DD-NNN scoped by source.
|
|
func (r *PricelistRepository) GenerateVersionBySource(source string) (string, error) {
|
|
today := time.Now().Format("2006-01-02")
|
|
prefix := versionPrefixBySource(source)
|
|
|
|
var last models.Pricelist
|
|
err := r.db.Model(&models.Pricelist{}).
|
|
Select("version").
|
|
Where("source = ? AND version LIKE ?", source, prefix+"-"+today+"-%").
|
|
Order("version DESC").
|
|
Limit(1).
|
|
Take(&last).Error
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return fmt.Sprintf("%s-%s-001", prefix, today), nil
|
|
}
|
|
return "", fmt.Errorf("loading latest today's pricelist version: %w", err)
|
|
}
|
|
|
|
parts := strings.Split(last.Version, "-")
|
|
if len(parts) < 4 {
|
|
return "", fmt.Errorf("invalid pricelist version format: %s", last.Version)
|
|
}
|
|
|
|
n, err := strconv.Atoi(parts[len(parts)-1])
|
|
if err != nil {
|
|
return "", fmt.Errorf("parsing pricelist sequence %q: %w", parts[len(parts)-1], err)
|
|
}
|
|
|
|
return fmt.Sprintf("%s-%s-%03d", prefix, today, n+1), nil
|
|
}
|
|
|
|
func versionPrefixBySource(source string) string {
|
|
switch models.NormalizePricelistSource(source) {
|
|
case models.PricelistSourceWarehouse:
|
|
return "S"
|
|
case models.PricelistSourceCompetitor:
|
|
return "B"
|
|
default:
|
|
return "E"
|
|
}
|
|
}
|
|
|
|
// GetPriceForLotBySource returns item price for a lot from latest active pricelist of source.
|
|
func (r *PricelistRepository) GetPriceForLotBySource(source, lotName string) (float64, uint, error) {
|
|
latest, err := r.GetLatestActiveBySource(source)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
price, err := r.GetPriceForLot(latest.ID, lotName)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
return price, latest.ID, nil
|
|
}
|
|
|
|
// CanWrite checks if the current database user has INSERT permission on qt_pricelists
|
|
func (r *PricelistRepository) CanWrite() bool {
|
|
canWrite, _ := r.CanWriteDebug()
|
|
return canWrite
|
|
}
|
|
|
|
// CanWriteDebug checks write permission and returns debug info
|
|
// Uses raw SQL with explicit columns to avoid schema mismatch issues
|
|
func (r *PricelistRepository) CanWriteDebug() (bool, string) {
|
|
// Check if table exists first
|
|
var count int64
|
|
if err := r.db.Table("qt_pricelists").Count(&count).Error; err != nil {
|
|
return false, fmt.Sprintf("table check failed: %v", err)
|
|
}
|
|
|
|
// Use raw SQL with only essential columns that always exist
|
|
// This avoids GORM model validation and schema mismatch issues
|
|
tx := r.db.Begin()
|
|
if tx.Error != nil {
|
|
return false, fmt.Sprintf("begin tx failed: %v", tx.Error)
|
|
}
|
|
defer tx.Rollback() // Always rollback - this is just a permission test
|
|
|
|
testVersion := fmt.Sprintf("test-%06d", time.Now().Unix()%1000000)
|
|
|
|
// Raw SQL insert with only core columns
|
|
err := tx.Exec(`
|
|
INSERT INTO qt_pricelists (version, created_by, is_active)
|
|
VALUES (?, 'system', 1)
|
|
`, testVersion).Error
|
|
|
|
if err != nil {
|
|
// Check if it's a permission error vs other errors
|
|
errStr := err.Error()
|
|
if strings.Contains(errStr, "INSERT command denied") ||
|
|
strings.Contains(errStr, "Access denied") {
|
|
return false, "no write permission"
|
|
}
|
|
return false, fmt.Sprintf("insert failed: %v", err)
|
|
}
|
|
|
|
return true, "ok"
|
|
}
|
|
|
|
// IncrementUsageCount increments the usage count for a pricelist
|
|
func (r *PricelistRepository) IncrementUsageCount(id uint) error {
|
|
return r.db.Model(&models.Pricelist{}).Where("id = ?", id).
|
|
UpdateColumn("usage_count", gorm.Expr("usage_count + 1")).Error
|
|
}
|
|
|
|
// DecrementUsageCount decrements the usage count for a pricelist
|
|
func (r *PricelistRepository) DecrementUsageCount(id uint) error {
|
|
return r.db.Model(&models.Pricelist{}).Where("id = ?", id).
|
|
UpdateColumn("usage_count", gorm.Expr("GREATEST(usage_count - 1, 0)")).Error
|
|
}
|
|
|
|
// CountUsage returns number of configurations referencing pricelist.
|
|
func (r *PricelistRepository) CountUsage(id uint) (int64, error) {
|
|
var count int64
|
|
if err := r.db.Table("qt_configurations").Where("pricelist_id = ?", id).Count(&count).Error; err != nil {
|
|
return 0, fmt.Errorf("counting configurations for pricelist %d: %w", id, err)
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
// GetExpiredUnused returns pricelists that are expired and unused
|
|
func (r *PricelistRepository) GetExpiredUnused() ([]models.Pricelist, error) {
|
|
var pricelists []models.Pricelist
|
|
if err := r.db.Where("expires_at < ? AND usage_count = 0", time.Now()).
|
|
Find(&pricelists).Error; err != nil {
|
|
return nil, fmt.Errorf("getting expired pricelists: %w", err)
|
|
}
|
|
return pricelists, nil
|
|
}
|
|
|
|
// StreamItemsForExport streams pricelist items in batches with optimized query (uses JOIN to avoid N+1)
|
|
// The callback function is called for each batch of items
|
|
func (r *PricelistRepository) StreamItemsForExport(pricelistID uint, batchSize int, callback func(items []models.PricelistItem) error) error {
|
|
if batchSize <= 0 {
|
|
batchSize = 500
|
|
}
|
|
|
|
// Check if this is a warehouse pricelist
|
|
var pl models.Pricelist
|
|
isWarehouse := false
|
|
if err := r.db.Select("source").Where("id = ?", pricelistID).First(&pl).Error; err == nil {
|
|
isWarehouse = pl.Source == string(models.PricelistSourceWarehouse)
|
|
}
|
|
|
|
offset := 0
|
|
for {
|
|
var items []models.PricelistItem
|
|
|
|
// Optimized query with JOIN to get lot descriptions and categories in one go
|
|
err := r.db.Table("qt_pricelist_items AS pi").
|
|
Select("pi.*, COALESCE(l.lot_description, '') AS lot_description, COALESCE(l.lot_category, '') AS category").
|
|
Joins("LEFT JOIN lot AS l ON l.lot_name = pi.lot_name").
|
|
Where("pi.pricelist_id = ?", pricelistID).
|
|
Order("pi.lot_name").
|
|
Offset(offset).
|
|
Limit(batchSize).
|
|
Scan(&items).Error
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("streaming pricelist items: %w", err)
|
|
}
|
|
|
|
if len(items) == 0 {
|
|
break
|
|
}
|
|
|
|
// Enrich warehouse items with qty and partnumbers
|
|
if isWarehouse {
|
|
if err := r.enrichWarehouseItems(items); err != nil {
|
|
// Log but don't fail on enrichment error
|
|
// return fmt.Errorf("enriching warehouse items: %w", err)
|
|
}
|
|
}
|
|
|
|
// Call callback with this batch
|
|
if err := callback(items); err != nil {
|
|
return err
|
|
}
|
|
|
|
// If we got fewer items than batch size, we're done
|
|
if len(items) < batchSize {
|
|
break
|
|
}
|
|
|
|
offset += batchSize
|
|
}
|
|
|
|
return nil
|
|
}
|