Modularize Go files, extract JS to static, implement competitor pricelists

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>
This commit is contained in:
Mikhail Chusavitin
2026-03-13 07:44:10 +03:00
parent c0fecde34e
commit f48615e8a9
41 changed files with 11822 additions and 9008 deletions

View File

@@ -0,0 +1,132 @@
package repository
import (
"errors"
"time"
"git.mchus.pro/mchus/priceforge/internal/models"
"gorm.io/gorm"
)
type CompetitorRepository struct {
db *gorm.DB
}
func NewCompetitorRepository(db *gorm.DB) *CompetitorRepository {
return &CompetitorRepository{db: db}
}
func (r *CompetitorRepository) List() ([]models.Competitor, error) {
var competitors []models.Competitor
if err := r.db.Order("name ASC").Find(&competitors).Error; err != nil {
return nil, err
}
return competitors, nil
}
func (r *CompetitorRepository) GetByID(id uint64) (*models.Competitor, error) {
var c models.Competitor
if err := r.db.First(&c, id).Error; err != nil {
return nil, err
}
return &c, nil
}
func (r *CompetitorRepository) Create(c *models.Competitor) error {
return r.db.Create(c).Error
}
func (r *CompetitorRepository) Update(c *models.Competitor) error {
return r.db.Save(c).Error
}
func (r *CompetitorRepository) Delete(id uint64) error {
return r.db.Delete(&models.Competitor{}, id).Error
}
func (r *CompetitorRepository) SetActive(id uint64, active bool) error {
return r.db.Model(&models.Competitor{}).Where("id = ?", id).Update("is_active", active).Error
}
// GetLastQuoteForPN returns the most recent quote for (competitor_id, partnumber).
// Used for deduplication at import time: skip if price and qty unchanged.
func (r *CompetitorRepository) GetLastQuoteForPN(competitorID uint64, partnumber string) (price float64, qty float64, found bool, err error) {
var quote models.CompetitorQuote
dbErr := r.db.
Where("competitor_id = ? AND partnumber = ?", competitorID, partnumber).
Order("date DESC, id DESC").
First(&quote).Error
if errors.Is(dbErr, gorm.ErrRecordNotFound) {
return 0, 0, false, nil
}
if dbErr != nil {
return 0, 0, false, dbErr
}
return quote.Price, quote.Qty, true, nil
}
// InsertQuote inserts a new competitor quote row.
func (r *CompetitorRepository) InsertQuote(q *models.CompetitorQuote) error {
return r.db.Create(q).Error
}
// BulkInsertQuotes inserts multiple quotes in batches of 500.
func (r *CompetitorRepository) BulkInsertQuotes(quotes []*models.CompetitorQuote) error {
if len(quotes) == 0 {
return nil
}
return r.db.CreateInBatches(quotes, 500).Error
}
// GetLatestQuotesByPN returns the most recent (price, qty) per partnumber for a competitor.
// Used when building the pricelist; p/n → lot resolution happens in the service layer.
func (r *CompetitorRepository) GetLatestQuotesByPN(competitorID uint64) ([]models.CompetitorQuote, error) {
var quotes []models.CompetitorQuote
err := r.db.Raw(`
SELECT plc.*
FROM partnumber_log_competitors plc
INNER JOIN (
SELECT partnumber, MAX(date) AS max_date
FROM partnumber_log_competitors
WHERE competitor_id = ?
GROUP BY partnumber
) latest ON plc.partnumber = latest.partnumber
AND plc.date = latest.max_date
WHERE plc.competitor_id = ?
ORDER BY plc.partnumber ASC
`, competitorID, competitorID).Scan(&quotes).Error
return quotes, err
}
// CompetitorQuoteCounts holds aggregate quote statistics for one competitor.
type CompetitorQuoteCounts struct {
CompetitorID uint64 `gorm:"column:competitor_id"`
UniquePN int64 `gorm:"column:unique_pn"`
TotalQuotes int64 `gorm:"column:total_quotes"`
}
// GetQuoteCountsByCompetitor returns unique p/n count and total quote count per competitor.
func (r *CompetitorRepository) GetQuoteCountsByCompetitor() ([]CompetitorQuoteCounts, error) {
var results []CompetitorQuoteCounts
err := r.db.Raw(`
SELECT competitor_id,
COUNT(DISTINCT partnumber) AS unique_pn,
COUNT(*) AS total_quotes
FROM partnumber_log_competitors
GROUP BY competitor_id
`).Scan(&results).Error
return results, err
}
// ListQuotes returns all quotes for a competitor since a given date.
func (r *CompetitorRepository) ListQuotes(competitorID uint64, since time.Time) ([]models.CompetitorQuote, error) {
var quotes []models.CompetitorQuote
q := r.db.Where("competitor_id = ?", competitorID)
if !since.IsZero() {
q = q.Where("date >= ?", since)
}
if err := q.Order("date DESC, partnumber ASC").Find(&quotes).Error; err != nil {
return nil, err
}
return quotes, nil
}