package services import ( "archive/zip" "bytes" "strings" "testing" "time" "git.mchus.pro/mchus/quoteforge/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 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) { r := &lotResolver{ partnumberToLots: map[string][]string{ "pn-1": {"LOT_MAPPED"}, "pn-conflict": {"LOT_A", "LOT_B"}, }, exactLots: map[string]string{ "cpu_a": "CPU_A", }, allLots: []string{"CPU_A_LONG", "CPU_A", "ABC ", "ABC\t"}, } lot, typ, err := r.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 = r.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 = r.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 = r.resolve("abx") if err == nil { t.Fatalf("expected not found error") } _, _, err = r.resolve("pn-conflict") if err == nil || err != 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{ Lot: "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", 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{Lot: "OLD", Date: time.Now(), Price: 1}).Error; err != nil { t.Fatalf("seed old row: %v", err) } svc := NewStockImportService(db, nil) records := []models.StockLog{ {Lot: "NEW_1", Date: time.Now(), Price: 2}, {Lot: "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("lot").Find(&rows).Error; err != nil { t.Fatalf("read rows: %v", err) } if len(rows) != 2 || rows[0].Lot != "NEW_1" || rows[1].Lot != "NEW_2" { t.Fatalf("unexpected rows after replace: %#v", rows) } } 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", ` `) write("_rels/.rels", ` `) write("xl/workbook.xml", ` `) write("xl/_rels/workbook.xml.rels", ` `) makeCell := func(ref, value string) string { escaped := strings.ReplaceAll(value, "&", "&") escaped = strings.ReplaceAll(escaped, "<", "<") escaped = strings.ReplaceAll(escaped, ">", ">") return `` + escaped + `` } 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", ` `+headerCells.String()+` `+valueCells.String()+` `) if err := zw.Close(); err != nil { t.Fatalf("close zip: %v", err) } return buf.Bytes() }