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