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 only for lot_description (category is now stored in pricelist_items)
|
|
err := 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).
|
|
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
|
|
}
|