Files
PriceForge/internal/services/stock_import.go
Mikhail Chusavitin f48615e8a9 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>
2026-03-13 07:44:10 +03:00

465 lines
14 KiB
Go

package services
import (
"fmt"
"strings"
"time"
"git.mchus.pro/mchus/priceforge/internal/dbutil"
"git.mchus.pro/mchus/priceforge/internal/lotmatch"
"git.mchus.pro/mchus/priceforge/internal/models"
pricelistsvc "git.mchus.pro/mchus/priceforge/internal/services/pricelist"
"git.mchus.pro/mchus/priceforge/internal/warehouse"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type StockImportProgress struct {
Status string `json:"status"`
Message string `json:"message,omitempty"`
Current int `json:"current,omitempty"`
Total int `json:"total,omitempty"`
RowsTotal int `json:"rows_total,omitempty"`
ValidRows int `json:"valid_rows,omitempty"`
Inserted int `json:"inserted,omitempty"`
Deleted int64 `json:"deleted,omitempty"`
Unmapped int `json:"unmapped,omitempty"`
Conflicts int `json:"conflicts,omitempty"`
AutoMapped int `json:"auto_mapped,omitempty"`
ParseErrors int `json:"parse_errors,omitempty"`
QtyParseErrors int `json:"qty_parse_errors,omitempty"`
Ignored int `json:"ignored,omitempty"`
MappingSuggestions []StockMappingSuggestion `json:"mapping_suggestions,omitempty"`
ImportDate string `json:"import_date,omitempty"`
PricelistID uint `json:"warehouse_pricelist_id,omitempty"`
PricelistVer string `json:"warehouse_pricelist_version,omitempty"`
}
type StockImportResult struct {
RowsTotal int
ValidRows int
Inserted int
Deleted int64
Unmapped int
Conflicts int
AutoMapped int
ParseErrors int
QtyParseErrors int
Ignored int
MappingSuggestions []StockMappingSuggestion
ImportDate time.Time
WarehousePLID uint
WarehousePLVer string
}
type StockMappingSuggestion struct {
Partnumber string `json:"partnumber"`
Description string `json:"description,omitempty"`
Reason string `json:"reason,omitempty"`
}
type StockMappingRow struct {
Partnumber string `json:"partnumber"`
LotName string `json:"lot_name"`
Description *string `json:"description,omitempty"`
}
type stockIgnoreRule struct {
Target string
MatchType string
Pattern string
}
type StockImportService struct {
db *gorm.DB
pricelistSvc *pricelistsvc.Service
}
func NewStockImportService(db *gorm.DB, pricelistSvc *pricelistsvc.Service) *StockImportService {
return &StockImportService{
db: db,
pricelistSvc: pricelistSvc,
}
}
type stockImportRow struct {
Folder string
Article string
Description string
Vendor string
Price float64
Qty float64
QtyRaw string
QtyInvalid bool
}
type weightedPricePoint struct {
price float64
weight float64
}
func (s *StockImportService) Import(
filename string,
content []byte,
fileModTime time.Time,
createdBy string,
createPricelist bool,
onProgress func(StockImportProgress),
) (*StockImportResult, error) {
fmt.Printf("[StockImport] Starting import: filename=%s, size=%d bytes, create_pricelist=%v\n", filename, len(content), createPricelist)
if s.db == nil {
fmt.Println("[StockImport] ERROR: database is nil (offline mode)")
return nil, fmt.Errorf("offline mode: stock import unavailable")
}
if len(content) == 0 {
fmt.Println("[StockImport] ERROR: empty file")
return nil, fmt.Errorf("empty file")
}
report := func(p StockImportProgress) {
if onProgress != nil {
onProgress(p)
}
}
report(StockImportProgress{Status: "starting", Message: "Запуск импорта", Current: 0, Total: 100})
fmt.Println("[StockImport] Progress: starting")
rows, err := parseStockRows(filename, content)
if err != nil {
fmt.Printf("[StockImport] ERROR parsing file: %v\n", err)
return nil, err
}
if len(rows) == 0 {
fmt.Println("[StockImport] ERROR: no rows parsed")
return nil, fmt.Errorf("no rows parsed")
}
fmt.Printf("[StockImport] Parsed %d rows\n", len(rows))
report(StockImportProgress{Status: "parsing", Message: "Файл распарсен", RowsTotal: len(rows), Current: 10, Total: 100})
importDate := detectImportDate(content, filename, fileModTime)
report(StockImportProgress{
Status: "parsing",
Message: "Дата импорта определена",
ImportDate: importDate.Format("2006-01-02"),
Current: 15,
Total: 100,
})
partnumberMatcher, err := lotmatch.NewMappingMatcherFromDB(s.db)
if err != nil {
return nil, err
}
var (
records []models.StockLog
unmapped int
conflicts int
autoMapped int
parseErrors int
qtyParseErrors int
ignored int
suggestionsByPN = make(map[string]StockMappingSuggestion)
autoMappingsToAdd = make(map[string]models.PartnumberBookItem) // key -> mapping
seenRowsToUpsert = make(map[string]models.VendorPartnumberSeen)
)
ignoredSeenIndex, err := s.loadIgnoredSeenIndex()
if err != nil {
return nil, err
}
for _, row := range rows {
if strings.TrimSpace(row.Article) == "" {
parseErrors++
continue
}
if row.QtyInvalid {
qtyParseErrors++
parseErrors++
continue
}
partnumber := strings.TrimSpace(row.Article)
vendorRaw := strings.TrimSpace(row.Vendor)
seenKey := normalizeKey(partnumber)
if seenKey == "" {
continue
}
seen := models.VendorPartnumberSeen{
SourceType: "stock",
Vendor: vendorRaw,
Partnumber: partnumber,
LastSeenAt: time.Now(),
}
if trimmed := strings.TrimSpace(row.Description); trimmed != "" {
seen.Description = &trimmed
}
if existing, ok := seenRowsToUpsert[seenKey]; ok {
if strings.TrimSpace(existing.Vendor) == "" && strings.TrimSpace(seen.Vendor) != "" {
existing.Vendor = strings.TrimSpace(seen.Vendor)
}
if (existing.Description == nil || strings.TrimSpace(*existing.Description) == "") &&
seen.Description != nil && strings.TrimSpace(*seen.Description) != "" {
existing.Description = seen.Description
}
seenRowsToUpsert[seenKey] = existing
} else {
seenRowsToUpsert[seenKey] = seen
}
if isIgnoredBySeenIndex(ignoredSeenIndex, vendorRaw, partnumber) {
ignored++
continue
}
key := normalizeKey(partnumber)
description := strings.TrimSpace(row.Description)
// Check if already mapped
mappedLots := partnumberMatcher.MatchLotsWithVendor(partnumber, vendorRaw)
if len(mappedLots) == 0 {
// Try to auto-map based on prefix match
if matchedLot := partnumberMatcher.FindPrefixMatch(partnumber); matchedLot != "" {
// Collect for batch insert later
descPtr := &description
if description == "" {
descPtr = nil
}
autoMappingsToAdd[strings.ToLower(vendorRaw)+"|"+key] = models.PartnumberBookItem{
Partnumber: partnumber,
LotsJSON: fmt.Sprintf(`[{"lot_name":%q,"qty":1}]`, matchedLot),
Description: descPtr,
}
autoMapped++
} else {
// No prefix match found
unmapped++
suggestionsByPN[key] = upsertSuggestion(suggestionsByPN[key], StockMappingSuggestion{
Partnumber: partnumber,
Description: description,
Reason: "unmapped",
})
}
}
var comments *string
if trimmed := strings.TrimSpace(row.Description); trimmed != "" {
comments = &trimmed
}
var vendor *string
if trimmed := strings.TrimSpace(row.Vendor); trimmed != "" {
vendor = &trimmed
}
qty := row.Qty
records = append(records, models.StockLog{
Partnumber: partnumber,
Date: importDate,
Price: row.Price,
Comments: comments,
Vendor: vendor,
Qty: &qty,
})
}
if err := s.upsertSeenRows(seenRowsToUpsert); err != nil {
return nil, err
}
// Batch create auto-mappings (unique by vendor+partnumber)
if len(autoMappingsToAdd) > 0 {
mappingsToInsert := make([]models.PartnumberBookItem, 0, len(autoMappingsToAdd))
for _, mapping := range autoMappingsToAdd {
mappingsToInsert = append(mappingsToInsert, mapping)
}
if len(mappingsToInsert) > 0 {
query := dbutil.WithTimeout(s.db, 30*time.Second)
if err := query.Execute(func(db *gorm.DB) error {
return db.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(mappingsToInsert, 100).Error
}); err != nil {
// Keep import robust: mappings will remain unmapped and visible in suggestions.
}
}
}
suggestions := collectSortedSuggestions(suggestionsByPN, 200)
if len(records) == 0 {
return nil, fmt.Errorf("no valid rows after filtering")
}
report(StockImportProgress{
Status: "mapping",
Message: "Валидация строк завершена",
RowsTotal: len(rows),
ValidRows: len(records),
Unmapped: unmapped,
Conflicts: conflicts,
AutoMapped: autoMapped,
ParseErrors: parseErrors,
QtyParseErrors: qtyParseErrors,
Current: 40,
Total: 100,
})
deleted, inserted, err := s.replaceStockLogs(records)
if err != nil {
fmt.Printf("[StockImport] ERROR replacing stock logs: %v\n", err)
return nil, err
}
fmt.Printf("[StockImport] Stock logs updated: deleted=%d, inserted=%d\n", deleted, inserted)
var warehousePLID uint
var warehousePLVer string
if createPricelist {
report(StockImportProgress{
Status: "writing",
Message: "Данные stock_log обновлены",
Inserted: inserted,
Deleted: deleted,
Current: 50,
Total: 100,
ImportDate: importDate.Format("2006-01-02"),
})
// Build warehouse pricelist items
items, err := s.buildWarehousePricelistItems()
if err != nil {
fmt.Printf("[StockImport] ERROR building warehouse pricelist items: %v\n", err)
return nil, err
}
if len(items) == 0 {
fmt.Println("[StockImport] WARNING: no items for warehouse pricelist, skipping pricelist creation")
// Don't fail, just skip pricelist creation
} else {
if createdBy == "" {
createdBy = "unknown"
}
report(StockImportProgress{Status: "creating_pricelist", Message: "Создание warehouse прайслиста", Current: 60, Total: 100})
if s.pricelistSvc == nil {
fmt.Println("[StockImport] ERROR: pricelist service unavailable")
return nil, fmt.Errorf("pricelist service unavailable")
}
fmt.Printf("[StockImport] Creating warehouse pricelist with %d items\n", len(items))
pl, err := s.pricelistSvc.CreateForSourceWithProgress(createdBy, string(models.PricelistSourceWarehouse), items, func(p pricelistsvc.CreateProgress) {
current := 60 + int(float64(p.Current)/float64(p.Total)*30)
if current >= 100 {
current = 99
}
report(StockImportProgress{
Status: "creating_pricelist",
Message: p.Message,
Current: current,
Total: 100,
})
})
if err != nil {
fmt.Printf("[StockImport] ERROR creating warehouse pricelist: %v\n", err)
return nil, err
}
warehousePLID = pl.ID
warehousePLVer = pl.Version
fmt.Printf("[StockImport] Warehouse pricelist created: id=%d, version=%s\n", warehousePLID, warehousePLVer)
}
} else {
fmt.Println("[StockImport] Skipping pricelist creation (createPricelist=false)")
report(StockImportProgress{
Status: "writing",
Message: "Данные stock_log обновлены",
Inserted: inserted,
Deleted: deleted,
Current: 100,
Total: 100,
ImportDate: importDate.Format("2006-01-02"),
})
}
result := &StockImportResult{
RowsTotal: len(rows),
ValidRows: len(records),
Inserted: inserted,
Deleted: deleted,
Unmapped: unmapped,
Conflicts: conflicts,
AutoMapped: autoMapped,
ParseErrors: parseErrors,
QtyParseErrors: qtyParseErrors,
Ignored: ignored,
MappingSuggestions: suggestions,
ImportDate: importDate,
WarehousePLID: warehousePLID,
WarehousePLVer: warehousePLVer,
}
fmt.Printf("[StockImport] Import completed successfully: inserted=%d, deleted=%d, unmapped=%d, conflicts=%d\n",
inserted, deleted, unmapped, conflicts)
report(StockImportProgress{
Status: "completed",
Message: "Импорт завершен",
RowsTotal: result.RowsTotal,
ValidRows: result.ValidRows,
Inserted: result.Inserted,
Deleted: result.Deleted,
Unmapped: result.Unmapped,
Conflicts: result.Conflicts,
AutoMapped: result.AutoMapped,
ParseErrors: result.ParseErrors,
QtyParseErrors: result.QtyParseErrors,
Ignored: result.Ignored,
MappingSuggestions: result.MappingSuggestions,
ImportDate: result.ImportDate.Format("2006-01-02"),
PricelistID: result.WarehousePLID,
PricelistVer: result.WarehousePLVer,
Current: 100,
Total: 100,
})
return result, nil
}
func (s *StockImportService) replaceStockLogs(records []models.StockLog) (int64, int, error) {
var deleted int64
// Use longer timeout for large batch operations (up to 60 seconds)
query := dbutil.WithTimeout(s.db, 60*time.Second)
query.RetryAttempts = 0 // Don't retry transactions
err := query.Execute(func(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
res := tx.Exec("DELETE FROM stock_log")
if res.Error != nil {
return res.Error
}
deleted = res.RowsAffected
if err := tx.CreateInBatches(records, 500).Error; err != nil {
return err
}
return nil
})
})
if err != nil {
return 0, 0, err
}
return deleted, len(records), nil
}
func (s *StockImportService) buildWarehousePricelistItems() ([]pricelistsvc.CreateItemInput, error) {
warehouseItems, err := warehouse.ComputePricelistItemsFromStockLog(s.db)
if err != nil {
return nil, err
}
items := make([]pricelistsvc.CreateItemInput, 0, len(warehouseItems))
for _, item := range warehouseItems {
items = append(items, pricelistsvc.CreateItemInput{
LotName: item.LotName,
Price: item.Price,
PriceMethod: item.PriceMethod,
PricePeriodDays: 0,
})
}
return items, nil
}