Optimize task retention from 5 minutes to 30 seconds to reduce polling overhead since toast notifications are shown only once. Add conditional warehouse pricelist creation via checkbox. Fix category storage in warehouse pricelists to properly load from lot table. Replace SSE with task polling for all long operations. Add comprehensive logging for debugging while minimizing noise from polling endpoints. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
385 lines
12 KiB
Go
385 lines
12 KiB
Go
package services
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.mchus.pro/mchus/priceforge/internal/lotmatch"
|
|
"git.mchus.pro/mchus/priceforge/internal/models"
|
|
"github.com/glebarez/sqlite"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func TestParseMXLRows(t *testing.T) {
|
|
content := strings.Join([]string{
|
|
`MOXCEL`,
|
|
`{16,2,{1,1,{"ru","Папка"}},0},1,`,
|
|
`{16,2,{1,1,{"ru","Артикул"}},0},2,`,
|
|
`{16,2,{1,1,{"ru","Описание"}},0},3,`,
|
|
`{16,2,{1,1,{"ru","Вендор"}},0},4,`,
|
|
`{16,2,{1,1,{"ru","Стоимость"}},0},5,`,
|
|
`{16,2,{1,1,{"ru","Свободно"}},0},6,`,
|
|
`{16,2,{1,1,{"ru","Серверы"}},0},1,`,
|
|
`{16,2,{1,1,{"ru","CPU_X"}},0},2,`,
|
|
`{16,2,{1,1,{"ru","Процессор"}},0},3,`,
|
|
`{16,2,{1,1,{"ru","AMD"}},0},4,`,
|
|
`{16,2,{1,1,{"ru","125,50"}},0},5,`,
|
|
`{16,2,{1,1,{"ru","10"}},0},6,`,
|
|
}, "\n")
|
|
|
|
rows, err := parseMXLRows([]byte(content))
|
|
if err != nil {
|
|
t.Fatalf("parseMXLRows: %v", err)
|
|
}
|
|
if len(rows) != 1 {
|
|
t.Fatalf("expected 1 row, got %d", len(rows))
|
|
}
|
|
if rows[0].Article != "CPU_X" {
|
|
t.Fatalf("unexpected article: %s", rows[0].Article)
|
|
}
|
|
if rows[0].Price != 125.50 {
|
|
t.Fatalf("unexpected price: %v", rows[0].Price)
|
|
}
|
|
if rows[0].Qty != 10 {
|
|
t.Fatalf("unexpected qty: %v", rows[0].Qty)
|
|
}
|
|
}
|
|
|
|
func TestParseMXLRows_EmptyQtyMarkedInvalid(t *testing.T) {
|
|
content := strings.Join([]string{
|
|
`MOXCEL`,
|
|
`{16,2,{1,1,{"ru","Папка"}},0},1,`,
|
|
`{16,2,{1,1,{"ru","Артикул"}},0},2,`,
|
|
`{16,2,{1,1,{"ru","Описание"}},0},3,`,
|
|
`{16,2,{1,1,{"ru","Вендор"}},0},4,`,
|
|
`{16,2,{1,1,{"ru","Стоимость"}},0},5,`,
|
|
`{16,2,{1,1,{"ru","Свободно"}},0},6,`,
|
|
`{16,2,{1,1,{"ru","Серверы"}},0},1,`,
|
|
`{16,2,{1,1,{"ru","CPU_X"}},0},2,`,
|
|
`{16,2,{1,1,{"ru","Процессор"}},0},3,`,
|
|
`{16,2,{1,1,{"ru","AMD"}},0},4,`,
|
|
`{16,2,{1,1,{"ru","125,50"}},0},5,`,
|
|
`{16,2,{1,1,{"ru",""}},0},6,`,
|
|
}, "\n")
|
|
|
|
rows, err := parseMXLRows([]byte(content))
|
|
if err != nil {
|
|
t.Fatalf("parseMXLRows: %v", err)
|
|
}
|
|
if len(rows) != 1 {
|
|
t.Fatalf("expected 1 row, got %d", len(rows))
|
|
}
|
|
if !rows[0].QtyInvalid {
|
|
t.Fatalf("expected QtyInvalid=true for empty qty")
|
|
}
|
|
}
|
|
|
|
func TestParseXLSXRows(t *testing.T) {
|
|
xlsx := buildMinimalXLSX(t, []string{
|
|
"Папка", "Артикул", "Описание", "Вендор", "Стоимость", "Свободно",
|
|
}, []string{
|
|
"Серверы", "CPU_A", "Процессор", "AMD", "99,25", "7",
|
|
})
|
|
|
|
rows, err := parseXLSXRows(xlsx)
|
|
if err != nil {
|
|
t.Fatalf("parseXLSXRows: %v", err)
|
|
}
|
|
if len(rows) != 1 {
|
|
t.Fatalf("expected 1 row, got %d", len(rows))
|
|
}
|
|
if rows[0].Article != "CPU_A" {
|
|
t.Fatalf("unexpected article: %s", rows[0].Article)
|
|
}
|
|
if rows[0].Price != 99.25 {
|
|
t.Fatalf("unexpected price: %v", rows[0].Price)
|
|
}
|
|
}
|
|
|
|
func TestLotResolverPrecedenceAndConflicts(t *testing.T) {
|
|
resolver := lotmatch.NewLotResolver(
|
|
[]models.LotPartnumber{
|
|
{Partnumber: "pn-1", LotName: "LOT_MAPPED"},
|
|
{Partnumber: "pn-conflict", LotName: "LOT_A"},
|
|
{Partnumber: "pn-conflict", LotName: "LOT_B"},
|
|
},
|
|
[]models.Lot{
|
|
{LotName: "CPU_A_LONG"},
|
|
{LotName: "CPU_A"},
|
|
{LotName: "ABC "},
|
|
{LotName: "ABC\t"},
|
|
},
|
|
)
|
|
|
|
lot, typ, err := resolver.Resolve("pn-1")
|
|
if err != nil || lot != "LOT_MAPPED" || typ != "mapping_table" {
|
|
t.Fatalf("mapping_table mismatch: lot=%s typ=%s err=%v", lot, typ, err)
|
|
}
|
|
|
|
lot, typ, err = resolver.Resolve("cpu_a")
|
|
if err != nil || lot != "CPU_A" || typ != "article_exact" {
|
|
t.Fatalf("article_exact mismatch: lot=%s typ=%s err=%v", lot, typ, err)
|
|
}
|
|
|
|
lot, typ, err = resolver.Resolve("cpu_a_long_suffix")
|
|
if err != nil || lot != "CPU_A_LONG" || typ != "prefix" {
|
|
t.Fatalf("prefix mismatch: lot=%s typ=%s err=%v", lot, typ, err)
|
|
}
|
|
|
|
_, _, err = resolver.Resolve("abx")
|
|
if err == nil || err != lotmatch.ErrResolveNotFound {
|
|
t.Fatalf("expected not found error, got %v", err)
|
|
}
|
|
|
|
_, _, err = resolver.Resolve("pn-conflict")
|
|
if err == nil || err != lotmatch.ErrResolveConflict {
|
|
t.Fatalf("expected conflict, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestImportNoValidRowsKeepsStockLog(t *testing.T) {
|
|
db := openTestDB(t)
|
|
if err := db.AutoMigrate(&models.StockLog{}); err != nil {
|
|
t.Fatalf("automigrate stock_log: %v", err)
|
|
}
|
|
|
|
existing := models.StockLog{
|
|
Partnumber: "CPU_A",
|
|
Date: time.Now(),
|
|
Price: 10,
|
|
}
|
|
if err := db.Create(&existing).Error; err != nil {
|
|
t.Fatalf("seed stock_log: %v", err)
|
|
}
|
|
|
|
svc := NewStockImportService(db, nil)
|
|
headerOnly := []byte(strings.Join([]string{
|
|
`MOXCEL`,
|
|
`{16,2,{1,1,{"ru","Папка"}},0},1,`,
|
|
`{16,2,{1,1,{"ru","Артикул"}},0},2,`,
|
|
`{16,2,{1,1,{"ru","Описание"}},0},3,`,
|
|
`{16,2,{1,1,{"ru","Вендор"}},0},4,`,
|
|
`{16,2,{1,1,{"ru","Стоимость"}},0},5,`,
|
|
`{16,2,{1,1,{"ru","Свободно"}},0},6,`,
|
|
}, "\n"))
|
|
|
|
if _, err := svc.Import("test.mxl", headerOnly, time.Now(), "tester", false, nil); err == nil {
|
|
t.Fatalf("expected import error")
|
|
}
|
|
|
|
var count int64
|
|
if err := db.Model(&models.StockLog{}).Count(&count).Error; err != nil {
|
|
t.Fatalf("count stock_log: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Fatalf("expected stock_log unchanged, got %d rows", count)
|
|
}
|
|
}
|
|
|
|
func TestReplaceStockLogs(t *testing.T) {
|
|
db := openTestDB(t)
|
|
if err := db.AutoMigrate(&models.StockLog{}); err != nil {
|
|
t.Fatalf("automigrate stock_log: %v", err)
|
|
}
|
|
|
|
if err := db.Create(&models.StockLog{Partnumber: "OLD", Date: time.Now(), Price: 1}).Error; err != nil {
|
|
t.Fatalf("seed old row: %v", err)
|
|
}
|
|
|
|
svc := NewStockImportService(db, nil)
|
|
records := []models.StockLog{
|
|
{Partnumber: "NEW_1", Date: time.Now(), Price: 2},
|
|
{Partnumber: "NEW_2", Date: time.Now(), Price: 3},
|
|
}
|
|
|
|
deleted, inserted, err := svc.replaceStockLogs(records)
|
|
if err != nil {
|
|
t.Fatalf("replaceStockLogs: %v", err)
|
|
}
|
|
if deleted != 1 || inserted != 2 {
|
|
t.Fatalf("unexpected replace stats deleted=%d inserted=%d", deleted, inserted)
|
|
}
|
|
|
|
var rows []models.StockLog
|
|
if err := db.Order("partnumber").Find(&rows).Error; err != nil {
|
|
t.Fatalf("read rows: %v", err)
|
|
}
|
|
if len(rows) != 2 || rows[0].Partnumber != "NEW_1" || rows[1].Partnumber != "NEW_2" {
|
|
t.Fatalf("unexpected rows after replace: %#v", rows)
|
|
}
|
|
}
|
|
|
|
func TestWeightedMedian(t *testing.T) {
|
|
got := weightedMedian([]weightedPricePoint{
|
|
{price: 10, weight: 1},
|
|
{price: 20, weight: 3},
|
|
{price: 50, weight: 1},
|
|
})
|
|
if got != 20 {
|
|
t.Fatalf("expected weighted median 20, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestWeightedMedianFallbackToMedianWhenNoWeights(t *testing.T) {
|
|
got := weightedMedian([]weightedPricePoint{
|
|
{price: 10, weight: 0},
|
|
{price: 20, weight: 0},
|
|
{price: 30, weight: 0},
|
|
})
|
|
if got != 20 {
|
|
t.Fatalf("expected fallback median 20, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildWarehousePricelistItems_UsesPrefixResolver(t *testing.T) {
|
|
db := openTestDB(t)
|
|
if err := db.AutoMigrate(&models.StockLog{}, &models.Lot{}, &models.LotPartnumber{}); err != nil {
|
|
t.Fatalf("automigrate: %v", err)
|
|
}
|
|
|
|
if err := db.Create(&models.Lot{LotName: "CPU_A"}).Error; err != nil {
|
|
t.Fatalf("seed lot: %v", err)
|
|
}
|
|
|
|
qty1 := 3.0
|
|
qty2 := 1.0
|
|
now := time.Now()
|
|
rows := []models.StockLog{
|
|
{Partnumber: "CPU_A-001", Date: now, Price: 100, Qty: &qty1},
|
|
{Partnumber: "CPU_A-XYZ", Date: now, Price: 120, Qty: &qty2},
|
|
}
|
|
if err := db.Create(&rows).Error; err != nil {
|
|
t.Fatalf("seed stock_log: %v", err)
|
|
}
|
|
|
|
svc := NewStockImportService(db, nil)
|
|
items, err := svc.buildWarehousePricelistItems()
|
|
if err != nil {
|
|
t.Fatalf("buildWarehousePricelistItems: %v", err)
|
|
}
|
|
if len(items) != 1 {
|
|
t.Fatalf("expected 1 item, got %d", len(items))
|
|
}
|
|
if items[0].LotName != "CPU_A" {
|
|
t.Fatalf("expected lot CPU_A, got %s", items[0].LotName)
|
|
}
|
|
if items[0].Price != 100 {
|
|
t.Fatalf("expected weighted median 100, got %v", items[0].Price)
|
|
}
|
|
}
|
|
|
|
func TestPartnumberMappings_WildcardMatch(t *testing.T) {
|
|
db := openTestDB(t)
|
|
if err := db.AutoMigrate(&models.LotPartnumber{}, &models.Lot{}); err != nil {
|
|
t.Fatalf("automigrate: %v", err)
|
|
}
|
|
|
|
mappings := []models.LotPartnumber{
|
|
{Partnumber: "R750*", LotName: "SERVER_R750"},
|
|
{Partnumber: "HDD-01", LotName: "HDD_01"},
|
|
}
|
|
if err := db.Create(&mappings).Error; err != nil {
|
|
t.Fatalf("seed mappings: %v", err)
|
|
}
|
|
if err := db.Create(&models.Lot{LotName: "MEM_DDR5_16G_4800"}).Error; err != nil {
|
|
t.Fatalf("seed lot: %v", err)
|
|
}
|
|
|
|
resolver, err := lotmatch.NewMappingMatcherFromDB(db)
|
|
if err != nil {
|
|
t.Fatalf("NewMappingMatcherFromDB: %v", err)
|
|
}
|
|
|
|
if got := resolver.MatchLots("R750XD"); len(got) != 1 || got[0] != "SERVER_R750" {
|
|
t.Fatalf("expected wildcard match SERVER_R750, got %#v", got)
|
|
}
|
|
if got := resolver.MatchLots("HDD-01"); len(got) != 1 || got[0] != "HDD_01" {
|
|
t.Fatalf("expected exact match HDD_01, got %#v", got)
|
|
}
|
|
if got := resolver.MatchLots("UNKNOWN"); len(got) != 0 {
|
|
t.Fatalf("expected no matches, got %#v", got)
|
|
}
|
|
if got := resolver.MatchLots("MEM_DDR5_16G_4800"); len(got) != 1 || got[0] != "MEM_DDR5_16G_4800" {
|
|
t.Fatalf("expected exact lot fallback, got %#v", got)
|
|
}
|
|
}
|
|
|
|
func openTestDB(t *testing.T) *gorm.DB {
|
|
t.Helper()
|
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
return db
|
|
}
|
|
|
|
func buildMinimalXLSX(t *testing.T, headers, values []string) []byte {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
zw := zip.NewWriter(&buf)
|
|
|
|
write := func(name, body string) {
|
|
w, err := zw.Create(name)
|
|
if err != nil {
|
|
t.Fatalf("create zip entry %s: %v", name, err)
|
|
}
|
|
if _, err := w.Write([]byte(body)); err != nil {
|
|
t.Fatalf("write zip entry %s: %v", name, err)
|
|
}
|
|
}
|
|
|
|
write("[Content_Types].xml", `<?xml version="1.0" encoding="UTF-8"?>
|
|
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
|
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
|
<Default Extension="xml" ContentType="application/xml"/>
|
|
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
|
|
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
|
|
</Types>`)
|
|
write("_rels/.rels", `<?xml version="1.0" encoding="UTF-8"?>
|
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
|
|
</Relationships>`)
|
|
write("xl/workbook.xml", `<?xml version="1.0" encoding="UTF-8"?>
|
|
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
|
<sheets>
|
|
<sheet name="Sheet1" sheetId="1" r:id="rId1" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"/>
|
|
</sheets>
|
|
</workbook>`)
|
|
write("xl/_rels/workbook.xml.rels", `<?xml version="1.0" encoding="UTF-8"?>
|
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
|
|
</Relationships>`)
|
|
|
|
makeCell := func(ref, value string) string {
|
|
escaped := strings.ReplaceAll(value, "&", "&")
|
|
escaped = strings.ReplaceAll(escaped, "<", "<")
|
|
escaped = strings.ReplaceAll(escaped, ">", ">")
|
|
return `<c r="` + ref + `" t="inlineStr"><is><t>` + escaped + `</t></is></c>`
|
|
}
|
|
|
|
cols := []string{"A", "B", "C", "D", "E", "F"}
|
|
var headerCells, valueCells strings.Builder
|
|
for i := 0; i < len(cols) && i < len(headers); i++ {
|
|
headerCells.WriteString(makeCell(cols[i]+"1", headers[i]))
|
|
}
|
|
for i := 0; i < len(cols) && i < len(values); i++ {
|
|
valueCells.WriteString(makeCell(cols[i]+"2", values[i]))
|
|
}
|
|
|
|
write("xl/worksheets/sheet1.xml", `<?xml version="1.0" encoding="UTF-8"?>
|
|
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
|
<sheetData>
|
|
<row r="1">`+headerCells.String()+`</row>
|
|
<row r="2">`+valueCells.String()+`</row>
|
|
</sheetData>
|
|
</worksheet>`)
|
|
|
|
if err := zw.Close(); err != nil {
|
|
t.Fatalf("close zip: %v", err)
|
|
}
|
|
return buf.Bytes()
|
|
}
|