Files
PriceForge/internal/services/vendor_mapping.go

470 lines
14 KiB
Go

package services
import (
"errors"
"fmt"
"sort"
"strings"
"time"
"git.mchus.pro/mchus/priceforge/internal/models"
"gorm.io/gorm"
)
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.LotBundleItem `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 (s *VendorMappingService) UpsertMapping(vendor, partnumber, lotName, description string, bundleItems []models.LotBundleItem) error {
if s.db == nil {
return fmt.Errorf("offline mode: vendor mappings unavailable")
}
vendor = normalizeVendor(vendor)
partnumber = normalizePartnumber(partnumber)
lotName = strings.TrimSpace(lotName)
description = strings.TrimSpace(description)
if partnumber == "" {
return fmt.Errorf("partnumber is required")
}
if lotName != "" {
var lotCount int64
if err := s.db.Model(&models.Lot{}).Where("lot_name = ?", lotName).Count(&lotCount).Error; err != nil {
return err
}
if lotCount == 0 {
return fmt.Errorf("lot not found: %s", lotName)
}
}
return s.db.Transaction(func(tx *gorm.DB) error {
var prev models.LotPartnumber
err := tx.Where("vendor = ? AND partnumber = ?", vendor, partnumber).First(&prev).Error
if err == nil && description == "" && prev.Description != nil {
description = strings.TrimSpace(*prev.Description)
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
var descPtr *string
if description != "" {
descPtr = &description
}
if err := tx.Where("vendor = ? AND partnumber = ?", vendor, partnumber).Delete(&models.LotPartnumber{}).Error; err != nil {
return err
}
if err := tx.Create(&models.LotPartnumber{
Vendor: vendor,
Partnumber: partnumber,
LotName: lotName,
Description: descPtr,
}).Error; err != nil {
return err
}
if strings.TrimSpace(lotName) != "" && len(bundleItems) > 0 {
if err := tx.Where("bundle_lot_name = ?", lotName).Delete(&models.LotBundleItem{}).Error; err != nil {
return err
}
if err := tx.Where("bundle_lot_name = ?", lotName).Delete(&models.LotBundle{}).Error; err != nil {
return err
}
if err := tx.Create(&models.LotBundle{
BundleLotName: lotName,
IsActive: true,
}).Error; err != nil {
return err
}
items := make([]models.LotBundleItem, 0, len(bundleItems))
for _, item := range bundleItems {
ln := strings.TrimSpace(item.LotName)
if ln == "" || item.Qty <= 0 {
continue
}
items = append(items, models.LotBundleItem{
BundleLotName: lotName,
LotName: ln,
Qty: item.Qty,
})
}
if len(items) > 0 {
if err := tx.Create(&items).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")
}
// Delete exact mapping first.
res := s.db.Where(
"LOWER(TRIM(vendor)) = LOWER(TRIM(?)) AND LOWER(TRIM(partnumber)) = LOWER(TRIM(?))",
vendor, partnumber,
).Delete(&models.LotPartnumber{})
if res.Error != nil {
return 0, res.Error
}
deleted := res.RowsAffected
// If nothing deleted for vendor-specific key, remove fallback mapping too.
if deleted == 0 && vendor != "" {
fb := s.db.Where(
"LOWER(TRIM(vendor)) = '' AND LOWER(TRIM(partnumber)) = LOWER(TRIM(?))",
partnumber,
).Delete(&models.LotPartnumber{})
if fb.Error != nil {
return deleted, fb.Error
}
deleted += fb.RowsAffected
}
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 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 lot_partnumbers
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, ''), NULLIF(lp.vendor, ''), '') as vendor,
k.partnumber,
COALESCE(lp.lot_name, '') as lot_name,
COALESCE(lp.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(lp.lot_name, '') = '' THEN 1 ELSE 0 END as unmapped,
CASE WHEN b.bundle_lot_name IS NULL THEN 'single' ELSE 'bundle' END as mapping_type,
COALESCE((SELECT COUNT(*) FROM qt_lot_bundle_items bi WHERE bi.bundle_lot_name = COALESCE(lp.lot_name, '')), 0) as bundle_item_count
`).
Joins(`LEFT JOIN (
SELECT
partnumber,
SUBSTRING_INDEX(
GROUP_CONCAT(COALESCE(TRIM(vendor), '') ORDER BY (TRIM(COALESCE(vendor, '')) <> '') DESC, vendor ASC SEPARATOR '\n'),
'\n',
1
) AS vendor,
SUBSTRING_INDEX(
GROUP_CONCAT(COALESCE(TRIM(lot_name), '') ORDER BY (TRIM(COALESCE(vendor, '')) <> '') DESC, vendor ASC SEPARATOR '\n'),
'\n',
1
) AS lot_name,
SUBSTRING_INDEX(
GROUP_CONCAT(COALESCE(TRIM(description), '') ORDER BY (TRIM(COALESCE(vendor, '')) <> '') DESC, vendor ASC SEPARATOR '\n'),
'\n',
1
) AS description
FROM lot_partnumbers
WHERE TRIM(COALESCE(partnumber, '')) <> ''
GROUP BY partnumber
) lp ON lp.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`).
Joins("LEFT JOIN qt_lot_bundles b ON b.bundle_lot_name = COALESCE(lp.lot_name, '')")
if q := strings.TrimSpace(search); q != "" {
like := "%" + q + "%"
base = base.Where("COALESCE(NULLIF(sa.vendor, ''), NULLIF(lp.vendor, ''), '') LIKE ? OR k.partnumber LIKE ? OR COALESCE(lp.lot_name, '') LIKE ? OR COALESCE(lp.description, sa.description, '') LIKE ?", like, like, like, like)
}
if unmappedOnly {
base = base.Where("COALESCE(lp.lot_name, '') = ''").
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
LotName string
Description string
Ignored bool
Sources string
LastSeenAt *time.Time
Unmapped bool
MappingType string
BundleItemCnt int64
}
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)
item := VendorMappingListRow{
Vendor: r.Vendor,
Partnumber: r.Partnumber,
LotName: r.LotName,
Description: r.Description,
Type: r.MappingType,
Ignored: r.Ignored,
Sources: sources,
Unmapped: r.Unmapped,
BundleItemCnt: r.BundleItemCnt,
}
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 mapping models.LotPartnumber
if err := s.db.Where("vendor = ? AND partnumber = ?", vendor, partnumber).First(&mapping).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
mapping = models.LotPartnumber{Vendor: vendor, Partnumber: partnumber}
if vendor != "" {
var fallback models.LotPartnumber
if fbErr := s.db.Where("vendor = '' AND partnumber = ?", partnumber).First(&fallback).Error; fbErr == nil {
mapping = fallback
}
}
} else {
return nil, err
}
}
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 == "" && mapping.Description != nil {
desc = strings.TrimSpace(*mapping.Description)
}
sources := make([]string, 0, len(sourceSet))
for k := range sourceSet {
sources = append(sources, k)
}
sort.Strings(sources)
detail := &VendorMappingDetail{
Vendor: vendor,
Partnumber: partnumber,
LotName: mapping.LotName,
Description: desc,
Type: "single",
Ignored: ignored,
Sources: sources,
}
if strings.TrimSpace(mapping.LotName) != "" {
var bundle models.LotBundle
if err := s.db.Where("bundle_lot_name = ?", mapping.LotName).First(&bundle).Error; err == nil {
detail.Type = "bundle"
var items []models.LotBundleItem
if err := s.db.Where("bundle_lot_name = ?", mapping.LotName).Order("lot_name ASC").Find(&items).Error; err != nil {
return nil, err
}
detail.Items = items
}
}
return detail, nil
}