433 lines
14 KiB
Go
433 lines
14 KiB
Go
package repository
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
|
"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, id 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, id 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).
|
|
Where("EXISTS (SELECT 1 FROM qt_pricelist_items WHERE qt_pricelist_items.pricelist_id = qt_pricelists.id)").
|
|
Order("created_at DESC, id 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
|
|
for i := 0; i < len(items); i += batchSize {
|
|
end := i + batchSize
|
|
if end > len(items) {
|
|
end = len(items)
|
|
}
|
|
if err := r.db.CreateInBatches(items[i:end], 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
|
|
if err := query.Order("lot_name").Offset(offset).Limit(limit).Find(&items).Error; err != nil {
|
|
return nil, 0, fmt.Errorf("listing pricelist items: %w", err)
|
|
}
|
|
|
|
// Enrich with lot descriptions
|
|
for i := range items {
|
|
var lot models.Lot
|
|
if err := r.db.Where("lot_name = ?", items[i].LotName).First(&lot).Error; err == nil {
|
|
items[i].LotDescription = lot.LotDescription
|
|
}
|
|
items[i].Category = strings.TrimSpace(items[i].LotCategory)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|