diff --git a/CLAUDE.md b/CLAUDE.md index 672e5a9..0d9836b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,6 +124,7 @@ type PricelistItem struct { ### ВАЖНО: - Категория НЕ виртуальное поле - она сохраняется в БД при создании прайслиста - JOIN с таблицей `lot` нужен только для `lot_description`, категория уже есть в `qt_pricelist_items` +- Никогда не получать категорию из имени элемента (lot name/названия). Всегда использовать категорию из `PricelistItem` (поле `lot_category` / JSON `category`). ## Notes - Главная страница должна вести на `/admin/pricing`. diff --git a/MEMORY.md b/MEMORY.md new file mode 100644 index 0000000..d7f42dd --- /dev/null +++ b/MEMORY.md @@ -0,0 +1,31 @@ +# PriceForge Memory + +## LOT Page Refactoring (2026-02-10) + +**What was done:** +- Created new dedicated `/lot` page with two tabs: + 1. **LOT tab**: Component/article management (table with search, sort, create LOT) + 2. **Сопоставления tab**: partnumber ↔ LOT mappings management + +- Removed LOT tab from "Администратор цен" (Pricing Admin) +- Removed "Сопоставление partnumber → LOT" section from Warehouse tab +- Updated main menu navigation: + - LOT link → /lot (new page) + - Администратор цен → /admin/pricing (Estimate, Склад, Конкуренты) + - Настройки (unchanged) + +**Files changed:** +- `web/templates/lot.html` (NEW) - Complete page with LOT and stock mappings tabs +- `web/templates/base.html` - Updated menu to link LOT to /lot +- `web/templates/admin_pricing.html` - Removed LOT tab, changed default to Estimate +- `internal/handlers/web.go` - Added lot.html to template list, added Lot() handler +- `cmd/pfs/main.go` - Added /lot route + +**Technical notes:** +- lot.html uses same API endpoints as admin_pricing for data loading +- Stock mappings functions copied from admin_pricing.html but adapted for new page +- Default tab for Pricing Admin is now 'estimate' instead of 'lots' + +## Data Rules (2026-02-18) + +- Never derive category from item name (lot name). Always use category from `PricelistItem` (`lot_category` / JSON `category`). diff --git a/internal/models/category.go b/internal/models/category.go index ca970c4..a4a28f3 100644 --- a/internal/models/category.go +++ b/internal/models/category.go @@ -9,6 +9,9 @@ type Category struct { IsRequired bool `gorm:"default:false" json:"is_required"` } +// DefaultLotCategoryCode is used when a lot has no category in DB. +const DefaultLotCategoryCode = "PART_" + func (Category) TableName() string { return "qt_categories" } diff --git a/internal/services/component.go b/internal/services/component.go index 8eed3ac..ab2e07f 100644 --- a/internal/services/component.go +++ b/internal/services/component.go @@ -183,13 +183,18 @@ func (s *ComponentService) ImportFromLot() (int, error) { imported := 0 for _, lot := range lots { - // Use lot_category from database if available, otherwise parse from lot_name + // Use lot_category from database only (never derive from lot_name). + // If empty, assign default and persist to DB. var category string if lot.LotCategory != nil && *lot.LotCategory != "" { category = strings.ToUpper(*lot.LotCategory) } else { - category, _ = ParsePartNumber(lot.LotName) - category = strings.ToUpper(category) + category = models.DefaultLotCategoryCode + if s.componentRepo != nil { + _ = s.componentRepo.DB().Model(&models.Lot{}). + Where("lot_name = ?", lot.LotName). + Update("lot_category", category).Error + } } _, model := ParsePartNumber(lot.LotName) @@ -200,13 +205,15 @@ func (s *ComponentService) ImportFromLot() (int, error) { Specs: make(models.Specs), } - if catID, ok := categoryMap[category]; ok { - metadata.CategoryID = &catID - } else { - // Create new category if it doesn't exist - newCat, err := s.categoryRepo.CreateIfNotExists(category) - if err == nil && newCat != nil { - metadata.CategoryID = &newCat.ID + if category != "" { + if catID, ok := categoryMap[category]; ok { + metadata.CategoryID = &catID + } else { + // Create new category if it doesn't exist + newCat, err := s.categoryRepo.CreateIfNotExists(category) + if err == nil && newCat != nil { + metadata.CategoryID = &newCat.ID + } } } diff --git a/internal/services/pricelist/service.go b/internal/services/pricelist/service.go index 3e932f2..c468073 100644 --- a/internal/services/pricelist/service.go +++ b/internal/services/pricelist/service.go @@ -189,14 +189,27 @@ func (s *Service) CreateForSourceWithProgress(createdBy, source string, sourceIt } categoryMap := make(map[string]*string) + missingCategoryLots := make([]string, 0) + defaultCategory := models.DefaultLotCategoryCode if len(lotNames) > 0 { var lots []models.Lot if err := s.db.Where("lot_name IN ?", lotNames).Find(&lots).Error; err == nil { for _, lot := range lots { + if lot.LotCategory == nil || strings.TrimSpace(*lot.LotCategory) == "" { + categoryMap[lot.LotName] = &defaultCategory + missingCategoryLots = append(missingCategoryLots, lot.LotName) + continue + } categoryMap[lot.LotName] = lot.LotCategory } } } + if len(missingCategoryLots) > 0 { + ensureCategoryExists(s.db, defaultCategory) + _ = s.db.Model(&models.Lot{}). + Where("lot_name IN ?", missingCategoryLots). + Update("lot_category", defaultCategory).Error + } items = make([]models.PricelistItem, 0, len(sourceItems)) for _, srcItem := range sourceItems { @@ -204,11 +217,9 @@ func (s *Service) CreateForSourceWithProgress(createdBy, source string, sourceIt if lotName == "" || srcItem.Price <= 0 { continue } - category := strings.TrimSpace(srcItem.Category) - if category == "" { - if catPtr, ok := categoryMap[lotName]; ok && catPtr != nil { - category = *catPtr - } + var category string + if catPtr, ok := categoryMap[lotName]; ok && catPtr != nil { + category = *catPtr } items = append(items, models.PricelistItem{ PricelistID: pricelist.ID, @@ -241,14 +252,27 @@ func (s *Service) CreateForSourceWithProgress(createdBy, source string, sourceIt } categoryMap := make(map[string]*string) + missingCategoryLots := make([]string, 0) + defaultCategory := models.DefaultLotCategoryCode if len(lotNames) > 0 { var lots []models.Lot if err := s.db.Where("lot_name IN ?", lotNames).Find(&lots).Error; err == nil { for _, lot := range lots { + if lot.LotCategory == nil || strings.TrimSpace(*lot.LotCategory) == "" { + categoryMap[lot.LotName] = &defaultCategory + missingCategoryLots = append(missingCategoryLots, lot.LotName) + continue + } categoryMap[lot.LotName] = lot.LotCategory } } } + if len(missingCategoryLots) > 0 { + ensureCategoryExists(s.db, defaultCategory) + _ = s.db.Model(&models.Lot{}). + Where("lot_name IN ?", missingCategoryLots). + Update("lot_category", defaultCategory).Error + } // Create pricelist items with all price settings items = make([]models.PricelistItem, 0, len(metadata)) @@ -295,6 +319,23 @@ func (s *Service) CreateForSourceWithProgress(createdBy, source string, sourceIt return pricelist, nil } +func ensureCategoryExists(db *gorm.DB, code string) { + var count int64 + if err := db.Model(&models.Category{}).Where("code = ?", code).Count(&count).Error; err != nil || count > 0 { + return + } + var maxOrder int + if err := db.Model(&models.Category{}).Select("COALESCE(MAX(display_order), 0)").Scan(&maxOrder).Error; err != nil { + return + } + _ = db.Create(&models.Category{ + Code: code, + Name: code, + NameRu: code, + DisplayOrder: maxOrder + 1, + }).Error +} + func isVersionConflictError(err error) bool { if errors.Is(err, gorm.ErrDuplicatedKey) { return true diff --git a/internal/warehouse/snapshot.go b/internal/warehouse/snapshot.go index 9e906c7..56a51c2 100644 --- a/internal/warehouse/snapshot.go +++ b/internal/warehouse/snapshot.go @@ -85,12 +85,43 @@ func ComputePricelistItemsFromStockLog(db *gorm.DB) ([]SnapshotItem, error) { } } } + defaultCategory := models.DefaultLotCategoryCode + missingCategoryLots := make([]string, 0) + for i := range items { + if strings.TrimSpace(items[i].Category) == "" { + items[i].Category = defaultCategory + missingCategoryLots = append(missingCategoryLots, items[i].LotName) + } + } + if len(missingCategoryLots) > 0 { + ensureCategoryExists(db, defaultCategory) + _ = db.Model(&models.Lot{}). + Where("lot_name IN ?", missingCategoryLots). + Update("lot_category", defaultCategory).Error + } } sort.Slice(items, func(i, j int) bool { return items[i].LotName < items[j].LotName }) return items, nil } +func ensureCategoryExists(db *gorm.DB, code string) { + var count int64 + if err := db.Model(&models.Category{}).Where("code = ?", code).Count(&count).Error; err != nil || count > 0 { + return + } + var maxOrder int + if err := db.Model(&models.Category{}).Select("COALESCE(MAX(display_order), 0)").Scan(&maxOrder).Error; err != nil { + return + } + _ = db.Create(&models.Category{ + Code: code, + Name: code, + NameRu: code, + DisplayOrder: maxOrder + 1, + }).Error +} + // LoadLotMetrics returns stock qty and partnumbers for selected lots. // If latestOnly is true, qty/partnumbers from stock_log are calculated only for latest import date. func LoadLotMetrics(db *gorm.DB, lotNames []string, latestOnly bool) (map[string]float64, map[string][]string, error) {