557 lines
16 KiB
Go
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
|
|
}
|