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>
465 lines
14 KiB
Go
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
|
|
}
|