Files
PriceForge/internal/services/vendor_mapping.go
2026-03-07 23:11:42 +03:00

557 lines
16 KiB
Go

package services
import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
"git.mchus.pro/mchus/priceforge/internal/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type VendorMappingService struct {
db *gorm.DB
}
type VendorMappingListRow struct {
Vendor string `json:"vendor"`
Partnumber string `json:"partnumber"`
LotName string `json:"lot_name"`
Description string `json:"description,omitempty"`
Type string `json:"type"`
Ignored bool `json:"ignored"`
Sources []string `json:"sources,omitempty"`
Unmapped bool `json:"unmapped"`
HasCalcError bool `json:"has_calc_error"`
BundleItemCnt int64 `json:"bundle_item_count"`
LastSeenAt string `json:"last_seen_at,omitempty"`
}
type VendorMappingDetail struct {
Vendor string `json:"vendor"`
Partnumber string `json:"partnumber"`
LotName string `json:"lot_name"`
Description string `json:"description,omitempty"`
Type string `json:"type"`
Ignored bool `json:"ignored"`
Sources []string `json:"sources,omitempty"`
Items []models.PartnumberBookLot `json:"items,omitempty"`
CalcErrors []string `json:"calc_errors,omitempty"`
}
func NewVendorMappingService(db *gorm.DB) *VendorMappingService {
return &VendorMappingService{db: db}
}
func normalizeVendor(v string) string {
return strings.TrimSpace(v)
}
func normalizePartnumber(v string) string {
return strings.TrimSpace(v)
}
func normalizeVendorMappingItems(lotName string, rawItems []models.PartnumberBookLot) ([]models.PartnumberBookLot, error) {
items := make([]models.PartnumberBookLot, 0, len(rawItems)+1)
if len(rawItems) == 0 && strings.TrimSpace(lotName) != "" {
items = append(items, models.PartnumberBookLot{
LotName: strings.TrimSpace(lotName),
Qty: 1,
})
}
for _, item := range rawItems {
ln := strings.TrimSpace(item.LotName)
if ln == "" {
continue
}
if item.Qty <= 0 {
return nil, fmt.Errorf("qty must be greater than 0 for lot %s", ln)
}
items = append(items, models.PartnumberBookLot{
LotName: ln,
Qty: item.Qty,
})
}
if len(items) == 0 {
return nil, fmt.Errorf("at least one lot is required")
}
merged := make(map[string]float64, len(items))
order := make([]string, 0, len(items))
for _, item := range items {
if _, ok := merged[item.LotName]; !ok {
order = append(order, item.LotName)
}
merged[item.LotName] += item.Qty
}
result := make([]models.PartnumberBookLot, 0, len(merged))
for _, lot := range order {
result = append(result, models.PartnumberBookLot{
LotName: lot,
Qty: merged[lot],
})
}
sort.Slice(result, func(i, j int) bool {
return result[i].LotName < result[j].LotName
})
return result, nil
}
func (s *VendorMappingService) validateLots(items []models.PartnumberBookLot) error {
lotSet := make(map[string]struct{}, len(items))
lots := make([]string, 0, len(items))
for _, item := range items {
lot := strings.TrimSpace(item.LotName)
if lot == "" {
continue
}
if _, ok := lotSet[lot]; ok {
continue
}
lotSet[lot] = struct{}{}
lots = append(lots, lot)
}
if len(lots) == 0 {
return fmt.Errorf("at least one lot is required")
}
var existing []string
if err := s.db.Model(&models.Lot{}).Where("lot_name IN ?", lots).Pluck("lot_name", &existing).Error; err != nil {
return err
}
existingSet := make(map[string]struct{}, len(existing))
for _, lot := range existing {
existingSet[strings.TrimSpace(lot)] = struct{}{}
}
for _, lot := range lots {
if _, ok := existingSet[lot]; !ok {
return fmt.Errorf("lot not found: %s", lot)
}
}
return nil
}
func parseVendorMappingItems(lotsJSON string, fallbackLot string) []models.PartnumberBookLot {
lotsJSON = strings.TrimSpace(lotsJSON)
if lotsJSON != "" {
var items []models.PartnumberBookLot
if err := json.Unmarshal([]byte(lotsJSON), &items); err == nil {
out := make([]models.PartnumberBookLot, 0, len(items))
for _, item := range items {
lot := strings.TrimSpace(item.LotName)
if lot == "" || item.Qty <= 0 {
continue
}
out = append(out, models.PartnumberBookLot{LotName: lot, Qty: item.Qty})
}
if len(out) > 0 {
sort.Slice(out, func(i, j int) bool { return out[i].LotName < out[j].LotName })
return out
}
}
}
if strings.TrimSpace(fallbackLot) == "" {
return nil
}
return []models.PartnumberBookLot{{LotName: strings.TrimSpace(fallbackLot), Qty: 1}}
}
func summarizeVendorMappingLots(items []models.PartnumberBookLot) string {
if len(items) == 0 {
return ""
}
if len(items) == 1 {
return items[0].LotName
}
return fmt.Sprintf("%s +%d", items[0].LotName, len(items)-1)
}
func (s *VendorMappingService) UpsertMapping(vendor, partnumber, lotName, description string, bundleItems []models.PartnumberBookLot) error {
return s.UpsertMappingWithOriginalVendor(vendor, vendor, partnumber, lotName, description, bundleItems)
}
func (s *VendorMappingService) UpsertMappingWithOriginalVendor(originalVendor, vendor, partnumber, lotName, description string, bundleItems []models.PartnumberBookLot) error {
if s.db == nil {
return fmt.Errorf("offline mode: vendor mappings unavailable")
}
originalVendor = normalizeVendor(originalVendor)
vendor = normalizeVendor(vendor)
partnumber = normalizePartnumber(partnumber)
lotName = strings.TrimSpace(lotName)
description = strings.TrimSpace(description)
if partnumber == "" {
return fmt.Errorf("partnumber is required")
}
items, err := normalizeVendorMappingItems(lotName, bundleItems)
if err != nil {
return err
}
if err := s.validateLots(items); err != nil {
return err
}
lotsJSON, err := json.Marshal(items)
if err != nil {
return fmt.Errorf("marshal lots_json: %w", err)
}
return s.db.Transaction(func(tx *gorm.DB) error {
var prevCatalog models.PartnumberBookItem
catalogErr := tx.Where("partnumber = ?", partnumber).First(&prevCatalog).Error
if description == "" && catalogErr == nil && prevCatalog.Description != nil {
description = strings.TrimSpace(*prevCatalog.Description)
}
if catalogErr != nil && !errors.Is(catalogErr, gorm.ErrRecordNotFound) {
return catalogErr
}
var descPtr *string
if description != "" {
descPtr = &description
}
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "partnumber"}},
DoUpdates: clause.Assignments(map[string]any{
"lots_json": string(lotsJSON),
"description": descPtr,
}),
}).Create(&models.PartnumberBookItem{
Partnumber: partnumber,
LotsJSON: string(lotsJSON),
Description: descPtr,
}).Error; err != nil {
return err
}
now := time.Now()
var existing models.VendorPartnumberSeen
err = tx.Where("LOWER(TRIM(partnumber)) = LOWER(TRIM(?))", partnumber).First(&existing).Error
if err == nil {
updates := map[string]any{
"last_seen_at": now,
}
if strings.TrimSpace(existing.SourceType) != "stock" {
updates["source_type"] = "manual"
}
if strings.TrimSpace(existing.Vendor) == "" && vendor != "" {
updates["vendor"] = vendor
}
if descPtr != nil {
updates["description"] = *descPtr
}
return tx.Model(&models.VendorPartnumberSeen{}).Where("id = ?", existing.ID).Updates(updates).Error
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
seen := models.VendorPartnumberSeen{
SourceType: "manual",
Vendor: vendor,
Partnumber: partnumber,
LastSeenAt: now,
Description: func() *string {
if descPtr == nil {
return nil
}
v := *descPtr
return &v
}(),
}
return tx.Create(&seen).Error
})
}
func (s *VendorMappingService) SetIgnore(vendor, partnumber, ignoredBy string, ignored bool) error {
if s.db == nil {
return fmt.Errorf("offline mode: vendor mappings unavailable")
}
vendor = normalizeVendor(vendor)
partnumber = normalizePartnumber(partnumber)
if partnumber == "" {
return fmt.Errorf("partnumber is required")
}
now := time.Now()
return s.db.Transaction(func(tx *gorm.DB) error {
updates := map[string]any{"is_ignored": ignored}
if ignored {
updates["ignored_at"] = now
if strings.TrimSpace(ignoredBy) != "" {
updates["ignored_by"] = strings.TrimSpace(ignoredBy)
}
} else {
updates["ignored_at"] = nil
updates["ignored_by"] = nil
}
res := tx.Model(&models.VendorPartnumberSeen{}).
Where("LOWER(TRIM(partnumber)) = LOWER(TRIM(?))", partnumber).
Updates(updates)
if res.Error != nil {
return res.Error
}
if res.RowsAffected > 0 {
return nil
}
seen := models.VendorPartnumberSeen{
SourceType: "manual",
Vendor: vendor,
Partnumber: partnumber,
LastSeenAt: now,
IsIgnored: ignored,
}
if ignored {
seen.IgnoredAt = &now
if strings.TrimSpace(ignoredBy) != "" {
v := strings.TrimSpace(ignoredBy)
seen.IgnoredBy = &v
}
}
return tx.Create(&seen).Error
})
}
func (s *VendorMappingService) DeleteMapping(vendor, partnumber string) (int64, error) {
if s.db == nil {
return 0, fmt.Errorf("offline mode: vendor mappings unavailable")
}
vendor = normalizeVendor(vendor)
partnumber = normalizePartnumber(partnumber)
if partnumber == "" {
return 0, fmt.Errorf("partnumber is required")
}
var deleted int64
err := s.db.Transaction(func(tx *gorm.DB) error {
catalog := tx.Where(
"LOWER(TRIM(partnumber)) = LOWER(TRIM(?))",
partnumber,
).Delete(&models.PartnumberBookItem{})
if catalog.Error != nil {
return catalog.Error
}
deleted += catalog.RowsAffected
// Also remove "seen" rows so the line disappears completely from the global list.
seen := tx.Where(
"LOWER(TRIM(vendor)) = LOWER(TRIM(?)) AND LOWER(TRIM(partnumber)) = LOWER(TRIM(?))",
vendor, partnumber,
).Delete(&models.VendorPartnumberSeen{})
if seen.Error != nil {
return seen.Error
}
deleted += seen.RowsAffected
if seen.RowsAffected == 0 && vendor != "" {
seenAnyVendor := tx.Where(
"LOWER(TRIM(partnumber)) = LOWER(TRIM(?))",
partnumber,
).Delete(&models.VendorPartnumberSeen{})
if seenAnyVendor.Error != nil {
return seenAnyVendor.Error
}
deleted += seenAnyVendor.RowsAffected
}
return nil
})
if err != nil {
return deleted, err
}
return deleted, nil
}
func (s *VendorMappingService) List(page, perPage int, search string, unmappedOnly, ignoredOnly bool) ([]VendorMappingListRow, int64, error) {
if s.db == nil {
return nil, 0, fmt.Errorf("offline mode: vendor mappings unavailable")
}
if err := purgeSeenLotNames(s.db); err != nil {
return nil, 0, err
}
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 50
}
if perPage > 500 {
perPage = 500
}
offset := (page - 1) * perPage
base := s.db.Table(`(
SELECT TRIM(partnumber) AS partnumber
FROM qt_partnumber_book_items
WHERE TRIM(COALESCE(partnumber, '')) <> ''
UNION
SELECT TRIM(partnumber) AS partnumber
FROM qt_vendor_partnumber_seen
WHERE TRIM(COALESCE(partnumber, '')) <> ''
) k`).
Select(`
COALESCE(NULLIF(sa.vendor, ''), '') as vendor,
k.partnumber,
COALESCE(pb.lots_json, '') as lots_json,
COALESCE(pb.description, sa.description, '') as description,
COALESCE(sa.ignored, 0) as ignored,
COALESCE(sa.sources, '') as sources,
sa.last_seen_at as last_seen_at,
CASE WHEN COALESCE(pb.lots_json, '') = '' THEN 1 ELSE 0 END as unmapped
`).
Joins(`LEFT JOIN (
SELECT
partnumber,
lots_json,
description
FROM qt_partnumber_book_items
WHERE TRIM(COALESCE(partnumber, '')) <> ''
) pb ON pb.partnumber = k.partnumber`).
Joins(`LEFT JOIN (
SELECT
partnumber,
SUBSTRING_INDEX(
GROUP_CONCAT(COALESCE(TRIM(vendor), '') ORDER BY (TRIM(COALESCE(vendor, '')) <> '') DESC, (source_type = 'stock') DESC, last_seen_at DESC, id DESC SEPARATOR '\n'),
'\n',
1
) AS vendor,
MAX(is_ignored) AS ignored,
GROUP_CONCAT(DISTINCT source_type ORDER BY source_type SEPARATOR ',') AS sources,
MAX(last_seen_at) AS last_seen_at,
MAX(description) AS description
FROM qt_vendor_partnumber_seen
GROUP BY partnumber
) sa ON sa.partnumber = k.partnumber`)
if q := strings.TrimSpace(search); q != "" {
like := "%" + q + "%"
base = base.Where("COALESCE(NULLIF(sa.vendor, ''), '') LIKE ? OR k.partnumber LIKE ? OR COALESCE(pb.lots_json, '') LIKE ? OR COALESCE(pb.description, sa.description, '') LIKE ?", like, like, like, like)
}
if unmappedOnly {
base = base.Where("COALESCE(pb.lots_json, '') = ''").
Where("COALESCE(sa.ignored, 0) = 0")
}
if ignoredOnly {
base = base.Where("COALESCE(sa.ignored, 0) = 1")
} else {
base = base.Where("COALESCE(sa.ignored, 0) = 0")
}
type row struct {
Vendor string
Partnumber string
LotsJSON string
Description string
Ignored bool
Sources string
LastSeenAt *time.Time
Unmapped bool
}
countQuery := s.db.Table("(")
_ = countQuery
var total int64
if err := s.db.Table("(?) as q", base).Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []row
if err := base.Order("k.partnumber ASC").Offset(offset).Limit(perPage).Scan(&rows).Error; err != nil {
return nil, 0, err
}
items := make([]VendorMappingListRow, 0, len(rows))
for _, r := range rows {
var sources []string
for _, s := range strings.Split(strings.TrimSpace(r.Sources), ",") {
s = strings.TrimSpace(s)
if s != "" {
sources = append(sources, s)
}
}
sort.Strings(sources)
itemsSummary := parseVendorMappingItems(r.LotsJSON, "")
itemType := "single"
if len(itemsSummary) > 1 {
itemType = "multi"
}
item := VendorMappingListRow{
Vendor: r.Vendor,
Partnumber: r.Partnumber,
LotName: summarizeVendorMappingLots(itemsSummary),
Description: r.Description,
Type: itemType,
Ignored: r.Ignored,
Sources: sources,
Unmapped: r.Unmapped,
BundleItemCnt: int64(len(itemsSummary)),
}
if r.LastSeenAt != nil {
item.LastSeenAt = r.LastSeenAt.Format(time.RFC3339)
}
items = append(items, item)
}
return items, total, nil
}
func (s *VendorMappingService) GetDetail(vendor, partnumber string) (*VendorMappingDetail, error) {
if s.db == nil {
return nil, fmt.Errorf("offline mode: vendor mappings unavailable")
}
vendor = normalizeVendor(vendor)
partnumber = normalizePartnumber(partnumber)
if partnumber == "" {
return nil, fmt.Errorf("partnumber is required")
}
var catalog models.PartnumberBookItem
catalogErr := s.db.Where("partnumber = ?", partnumber).First(&catalog).Error
if catalogErr != nil && !errors.Is(catalogErr, gorm.ErrRecordNotFound) {
return nil, catalogErr
}
var seenRows []models.VendorPartnumberSeen
if err := s.db.Where("LOWER(TRIM(partnumber)) = LOWER(TRIM(?))", partnumber).Find(&seenRows).Error; err != nil {
return nil, err
}
ignored := false
sourceSet := make(map[string]struct{})
desc := ""
for _, r := range seenRows {
if r.IsIgnored {
ignored = true
}
sourceSet[r.SourceType] = struct{}{}
if desc == "" && r.Description != nil {
desc = strings.TrimSpace(*r.Description)
}
}
if desc == "" && catalogErr == nil && catalog.Description != nil {
desc = strings.TrimSpace(*catalog.Description)
}
sources := make([]string, 0, len(sourceSet))
for k := range sourceSet {
sources = append(sources, k)
}
sort.Strings(sources)
items := parseVendorMappingItems(catalog.LotsJSON, "")
mappingType := "single"
if len(items) > 1 {
mappingType = "multi"
}
detail := &VendorMappingDetail{
Vendor: vendor,
Partnumber: partnumber,
LotName: summarizeVendorMappingLots(items),
Description: desc,
Type: mappingType,
Ignored: ignored,
Sources: sources,
Items: items,
}
return detail, nil
}