Add article generation and pricelist categories
This commit is contained in:
@@ -926,6 +926,23 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
c.JSON(http.StatusCreated, config)
|
c.JSON(http.StatusCreated, config)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
configs.POST("/preview-article", func(c *gin.Context) {
|
||||||
|
var req services.ArticlePreviewRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := configService.BuildArticlePreview(&req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"article": result.Article,
|
||||||
|
"warnings": result.Warnings,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
configs.GET("/:uuid", func(c *gin.Context) {
|
configs.GET("/:uuid", func(c *gin.Context) {
|
||||||
uuid := c.Param("uuid")
|
uuid := c.Param("uuid")
|
||||||
config, err := configService.GetByUUIDNoAuth(uuid)
|
config, err := configService.GetByUUIDNoAuth(uuid)
|
||||||
|
|||||||
297
csv_export.md
297
csv_export.md
@@ -1,297 +0,0 @@
|
|||||||
# CSV Export Pattern (Go + GORM)
|
|
||||||
|
|
||||||
## Архитектура (3-слойная)
|
|
||||||
|
|
||||||
### 1. Handler Layer (HTTP)
|
|
||||||
**Задачи**: Обработка HTTP-запроса, установка заголовков, инициация экспорта
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (h *PricelistHandler) ExportCSV(c *gin.Context) {
|
|
||||||
// 1. Валидация параметров
|
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
|
||||||
|
|
||||||
// 2. Получение метаданных для формирования имени файла
|
|
||||||
pl, err := h.service.GetByID(uint(id))
|
|
||||||
|
|
||||||
// 3. Установка HTTP-заголовков для скачивания
|
|
||||||
filename := fmt.Sprintf("pricelist_%s.csv", pl.Version)
|
|
||||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
|
||||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
|
||||||
|
|
||||||
// 4. UTF-8 BOM для Excel-совместимости
|
|
||||||
c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})
|
|
||||||
|
|
||||||
// 5. Настройка CSV writer
|
|
||||||
writer := csv.NewWriter(c.Writer)
|
|
||||||
writer.Comma = ';' // Точка с запятой для Excel
|
|
||||||
defer writer.Flush()
|
|
||||||
|
|
||||||
// 6. Динамические заголовки (зависят от типа данных)
|
|
||||||
isWarehouse := strings.ToLower(pl.Source) == "warehouse"
|
|
||||||
var header []string
|
|
||||||
if isWarehouse {
|
|
||||||
header = []string{"Артикул", "Категория", "Описание", "Доступно", "Partnumbers", "Цена, $", "Настройки"}
|
|
||||||
} else {
|
|
||||||
header = []string{"Артикул", "Категория", "Описание", "Цена, $", "Настройки"}
|
|
||||||
}
|
|
||||||
writer.Write(header)
|
|
||||||
|
|
||||||
// 7. Streaming в batches через callback
|
|
||||||
err = h.service.StreamItemsForExport(uint(id), 500, func(items []models.PricelistItem) error {
|
|
||||||
for _, item := range items {
|
|
||||||
row := buildRow(item, isWarehouse)
|
|
||||||
if err := writer.Write(row); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
writer.Flush() // Flush после каждого batch
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Service Layer
|
|
||||||
**Задачи**: Оркестрация, делегирование в репозиторий
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (s *Service) StreamItemsForExport(pricelistID uint, batchSize int, callback func(items []models.PricelistItem) error) error {
|
|
||||||
if s.repo == nil {
|
|
||||||
return fmt.Errorf("offline mode: cannot stream pricelist items")
|
|
||||||
}
|
|
||||||
return s.repo.StreamItemsForExport(pricelistID, batchSize, callback)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Repository Layer (Критичный)
|
|
||||||
**Задачи**: Batch-загрузка из БД, оптимизация запросов, enrichment
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (r *PricelistRepository) StreamItemsForExport(pricelistID uint, batchSize int, callback func(items []models.PricelistItem) error) error {
|
|
||||||
if batchSize <= 0 {
|
|
||||||
batchSize = 500 // Default batch size
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверка типа pricelist для conditional enrichment
|
|
||||||
var pl models.Pricelist
|
|
||||||
isWarehouse := false
|
|
||||||
if err := r.db.Select("source").Where("id = ?", pricelistID).First(&pl).Error; err == nil {
|
|
||||||
isWarehouse = pl.Source == string(models.PricelistSourceWarehouse)
|
|
||||||
}
|
|
||||||
|
|
||||||
offset := 0
|
|
||||||
for {
|
|
||||||
var items []models.PricelistItem
|
|
||||||
|
|
||||||
// ⚡ КЛЮЧЕВОЙ МОМЕНТ: JOIN для избежания N+1 запросов
|
|
||||||
err := r.db.Table("qt_pricelist_items AS pi").
|
|
||||||
Select("pi.*, COALESCE(l.lot_description, '') AS lot_description, COALESCE(l.lot_category, '') AS category").
|
|
||||||
Joins("LEFT JOIN lot AS l ON l.lot_name = pi.lot_name").
|
|
||||||
Where("pi.pricelist_id = ?", pricelistID).
|
|
||||||
Order("pi.lot_name").
|
|
||||||
Offset(offset).
|
|
||||||
Limit(batchSize).
|
|
||||||
Scan(&items).Error
|
|
||||||
|
|
||||||
if err != nil || len(items) == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Conditional enrichment для warehouse данных
|
|
||||||
if isWarehouse {
|
|
||||||
r.enrichWarehouseItems(items) // Добавление qty, partnumbers
|
|
||||||
}
|
|
||||||
|
|
||||||
// Вызов callback для обработки batch
|
|
||||||
if err := callback(items); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(items) < batchSize {
|
|
||||||
break // Последний batch
|
|
||||||
}
|
|
||||||
|
|
||||||
offset += batchSize
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Ключевые паттерны
|
|
||||||
|
|
||||||
### 1. Streaming (не загружать все в память)
|
|
||||||
```go
|
|
||||||
// ❌ НЕ ТАК:
|
|
||||||
var allItems []Item
|
|
||||||
db.Find(&allItems) // Может упасть на миллионах записей
|
|
||||||
|
|
||||||
// ✅ ТАК:
|
|
||||||
for offset := 0; ; offset += batchSize {
|
|
||||||
var batch []Item
|
|
||||||
db.Offset(offset).Limit(batchSize).Find(&batch)
|
|
||||||
processBatch(batch)
|
|
||||||
if len(batch) < batchSize {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Callback Pattern для гибкости
|
|
||||||
```go
|
|
||||||
// Service не знает о CSV - может использоваться для любого экспорта
|
|
||||||
func StreamItems(callback func([]Item) error) error
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. JOIN для избежания N+1
|
|
||||||
```go
|
|
||||||
// ❌ N+1 problem:
|
|
||||||
items := getItems()
|
|
||||||
for _, item := range items {
|
|
||||||
description := getLotDescription(item.LotName) // N запросов
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ JOIN:
|
|
||||||
db.Table("items AS i").
|
|
||||||
Select("i.*, COALESCE(l.description, '') AS description").
|
|
||||||
Joins("LEFT JOIN lots AS l ON l.name = i.lot_name")
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. UTF-8 BOM для Excel
|
|
||||||
```go
|
|
||||||
// Excel на Windows требует BOM для корректного отображения UTF-8
|
|
||||||
c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Точка с запятой для Excel
|
|
||||||
```go
|
|
||||||
writer := csv.NewWriter(c.Writer)
|
|
||||||
writer.Comma = ';' // Excel в русской локали использует ;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Graceful Error Handling
|
|
||||||
```go
|
|
||||||
// После начала streaming нельзя вернуть JSON
|
|
||||||
if err != nil {
|
|
||||||
// Уже начали писать CSV, поэтому пишем текст
|
|
||||||
c.String(http.StatusInternalServerError, "Export failed: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Conditional Enrichment Pattern
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Для warehouse прайслистов добавляем дополнительные поля
|
|
||||||
func (r *PricelistRepository) enrichWarehouseItems(items []models.PricelistItem) error {
|
|
||||||
// 1. Собрать уникальные lot_names
|
|
||||||
lots := make([]string, 0, len(items))
|
|
||||||
seen := make(map[string]struct{})
|
|
||||||
for _, item := range items {
|
|
||||||
if _, ok := seen[item.LotName]; !ok {
|
|
||||||
lots = append(lots, item.LotName)
|
|
||||||
seen[item.LotName] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Batch-загрузка метрик (qty, partnumbers)
|
|
||||||
qtyByLot, partnumbersByLot, err := warehouse.LoadLotMetrics(r.db, lots, true)
|
|
||||||
|
|
||||||
// 3. Обогащение items
|
|
||||||
for i := range items {
|
|
||||||
if qty, ok := qtyByLot[items[i].LotName]; ok {
|
|
||||||
items[i].AvailableQty = &qty
|
|
||||||
}
|
|
||||||
items[i].Partnumbers = partnumbersByLot[items[i].LotName]
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Virtual Fields Pattern
|
|
||||||
|
|
||||||
```go
|
|
||||||
type PricelistItem struct {
|
|
||||||
// Stored fields
|
|
||||||
ID uint `gorm:"primaryKey"`
|
|
||||||
LotName string `gorm:"size:255"`
|
|
||||||
Price float64 `gorm:"type:decimal(12,2)"`
|
|
||||||
|
|
||||||
// Virtual fields (populated via JOIN or programmatically)
|
|
||||||
LotDescription string `gorm:"-:migration" json:"lot_description,omitempty"`
|
|
||||||
Category string `gorm:"-:migration" json:"category,omitempty"`
|
|
||||||
AvailableQty *float64 `gorm:"-" json:"available_qty,omitempty"`
|
|
||||||
Partnumbers []string `gorm:"-" json:"partnumbers,omitempty"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- `gorm:"-:migration"` - не создавать колонку в БД, но маппить при SELECT
|
|
||||||
- `gorm:"-"` - полностью игнорировать при БД операциях
|
|
||||||
|
|
||||||
## Checklist для CSV Export
|
|
||||||
|
|
||||||
- [ ] HTTP заголовки: Content-Type, Content-Disposition
|
|
||||||
- [ ] UTF-8 BOM для Excel (0xEF, 0xBB, 0xBF)
|
|
||||||
- [ ] Разделитель (`;` для русской локали Excel)
|
|
||||||
- [ ] Streaming с batch processing (не загружать всё в память)
|
|
||||||
- [ ] JOIN для избежания N+1 запросов
|
|
||||||
- [ ] Flush после каждого batch
|
|
||||||
- [ ] Graceful error handling (нельзя JSON после начала streaming)
|
|
||||||
- [ ] Динамические заголовки (если нужно)
|
|
||||||
- [ ] Conditional enrichment (если данные зависят от типа)
|
|
||||||
|
|
||||||
## Когда использовать этот паттерн
|
|
||||||
|
|
||||||
✅ **Используй когда:**
|
|
||||||
- Экспорт больших датасетов (>1000 записей)
|
|
||||||
- Нужна Excel-совместимость
|
|
||||||
- Связанные данные из нескольких таблиц
|
|
||||||
- Conditional логика enrichment
|
|
||||||
|
|
||||||
❌ **Не нужен когда:**
|
|
||||||
- Малые датасеты (<100 записей) - можно загрузить всё сразу
|
|
||||||
- Экспорт JSON/XML - другие подходы
|
|
||||||
- Нет связанных данных - можно упростить
|
|
||||||
|
|
||||||
## Пример роутинга (Gin)
|
|
||||||
|
|
||||||
```go
|
|
||||||
// В файле роутера
|
|
||||||
func SetupRoutes(router *gin.Engine, handler *PricelistHandler) {
|
|
||||||
api := router.Group("/api")
|
|
||||||
{
|
|
||||||
pricelists := api.Group("/pricelists")
|
|
||||||
{
|
|
||||||
pricelists.GET("/:id/export", handler.ExportCSV)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Импорты
|
|
||||||
|
|
||||||
```go
|
|
||||||
import (
|
|
||||||
"encoding/csv"
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance Notes
|
|
||||||
|
|
||||||
1. **Batch Size**: 500-1000 оптимально для большинства случаев
|
|
||||||
2. **JOIN vs N+1**: JOIN на порядки быстрее при >100 записях
|
|
||||||
3. **Memory**: Streaming позволяет экспортировать миллионы записей с минимальной памятью
|
|
||||||
4. **Indexes**: Убедись что есть индексы на JOIN колонках
|
|
||||||
|
|
||||||
## Источник
|
|
||||||
|
|
||||||
Реализовано в проекте PriceForge:
|
|
||||||
- Handler: `internal/handlers/pricelist.go:245-346`
|
|
||||||
- Service: `internal/services/pricelist/service.go:373-379`
|
|
||||||
- Repository: `internal/repository/pricelist.go:475-533`
|
|
||||||
- Models: `internal/models/pricelist.go`
|
|
||||||
106
internal/article/categories.go
Normal file
106
internal/article/categories.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package article
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrMissingCategoryForLot is returned when a lot has no category in local_pricelist_items.lot_category.
|
||||||
|
var ErrMissingCategoryForLot = errors.New("missing_category_for_lot")
|
||||||
|
|
||||||
|
type MissingCategoryForLotError struct {
|
||||||
|
LotName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MissingCategoryForLotError) Error() string {
|
||||||
|
if e == nil || strings.TrimSpace(e.LotName) == "" {
|
||||||
|
return ErrMissingCategoryForLot.Error()
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s: %s", ErrMissingCategoryForLot.Error(), e.LotName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MissingCategoryForLotError) Unwrap() error {
|
||||||
|
return ErrMissingCategoryForLot
|
||||||
|
}
|
||||||
|
|
||||||
|
type Group string
|
||||||
|
|
||||||
|
const (
|
||||||
|
GroupCPU Group = "CPU"
|
||||||
|
GroupMEM Group = "MEM"
|
||||||
|
GroupGPU Group = "GPU"
|
||||||
|
GroupDISK Group = "DISK"
|
||||||
|
GroupNET Group = "NET"
|
||||||
|
GroupPSU Group = "PSU"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GroupForLotCategory maps pricelist lot_category codes into article groups.
|
||||||
|
// Unknown/unrelated categories return ok=false.
|
||||||
|
func GroupForLotCategory(cat string) (group Group, ok bool) {
|
||||||
|
c := strings.ToUpper(strings.TrimSpace(cat))
|
||||||
|
switch c {
|
||||||
|
case "CPU":
|
||||||
|
return GroupCPU, true
|
||||||
|
case "MEM":
|
||||||
|
return GroupMEM, true
|
||||||
|
case "GPU":
|
||||||
|
return GroupGPU, true
|
||||||
|
case "M2", "SSD", "HDD", "EDSFF", "HHHL":
|
||||||
|
return GroupDISK, true
|
||||||
|
case "NIC", "HCA", "DPU":
|
||||||
|
return GroupNET, true
|
||||||
|
case "HBA":
|
||||||
|
return GroupNET, true
|
||||||
|
case "PSU", "PS":
|
||||||
|
return GroupPSU, true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveLotCategoriesStrict resolves categories for lotNames using local_pricelist_items.lot_category
|
||||||
|
// for a given server pricelist id. If any lot is missing or has empty category, returns an error.
|
||||||
|
func ResolveLotCategoriesStrict(local *localdb.LocalDB, serverPricelistID uint, lotNames []string) (map[string]string, error) {
|
||||||
|
if local == nil {
|
||||||
|
return nil, fmt.Errorf("local db is nil")
|
||||||
|
}
|
||||||
|
cats, err := local.GetLocalLotCategoriesByServerPricelistID(serverPricelistID, lotNames)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, lot := range lotNames {
|
||||||
|
cat := strings.TrimSpace(cats[lot])
|
||||||
|
if cat == "" {
|
||||||
|
return nil, &MissingCategoryForLotError{LotName: lot}
|
||||||
|
}
|
||||||
|
cats[lot] = cat
|
||||||
|
}
|
||||||
|
return cats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeServerModel produces a stable article segment for the server model.
|
||||||
|
func NormalizeServerModel(model string) string {
|
||||||
|
trimmed := strings.TrimSpace(model)
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
upper := strings.ToUpper(trimmed)
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range upper {
|
||||||
|
if r >= 'A' && r <= 'Z' {
|
||||||
|
b.WriteRune(r)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r >= '0' && r <= '9' {
|
||||||
|
b.WriteRune(r)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r == '.' {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
56
internal/article/categories_test.go
Normal file
56
internal/article/categories_test.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package article
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveLotCategoriesStrict_MissingCategoryReturnsError(t *testing.T) {
|
||||||
|
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("init local db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = local.Close() })
|
||||||
|
|
||||||
|
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||||
|
ServerID: 1,
|
||||||
|
Source: "estimate",
|
||||||
|
Version: "S-2026-02-11-001",
|
||||||
|
Name: "test",
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
SyncedAt: time.Now(),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("save local pricelist: %v", err)
|
||||||
|
}
|
||||||
|
localPL, err := local.GetLocalPricelistByServerID(1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get local pricelist: %v", err)
|
||||||
|
}
|
||||||
|
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
||||||
|
{PricelistID: localPL.ID, LotName: "CPU_A", LotCategory: "", Price: 10},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("save local items: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = ResolveLotCategoriesStrict(local, 1, []string{"CPU_A"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrMissingCategoryForLot) {
|
||||||
|
t.Fatalf("expected ErrMissingCategoryForLot, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroupForLotCategory(t *testing.T) {
|
||||||
|
if g, ok := GroupForLotCategory("cpu"); !ok || g != GroupCPU {
|
||||||
|
t.Fatalf("expected cpu -> GroupCPU")
|
||||||
|
}
|
||||||
|
if g, ok := GroupForLotCategory("SFP"); ok || g != "" {
|
||||||
|
t.Fatalf("expected SFP to be excluded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
496
internal/article/generator.go
Normal file
496
internal/article/generator.go
Normal file
@@ -0,0 +1,496 @@
|
|||||||
|
package article
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BuildOptions struct {
|
||||||
|
ServerModel string
|
||||||
|
SupportCode string
|
||||||
|
ServerPricelist *uint
|
||||||
|
}
|
||||||
|
|
||||||
|
type BuildResult struct {
|
||||||
|
Article string
|
||||||
|
Warnings []string
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
reMemGiB = regexp.MustCompile(`(?i)(\d+)\s*(GB|G)`)
|
||||||
|
reMemTiB = regexp.MustCompile(`(?i)(\d+)\s*(TB|T)`)
|
||||||
|
reCapacityT = regexp.MustCompile(`(?i)(\d+(?:[.,]\d+)?)T`)
|
||||||
|
reCapacityG = regexp.MustCompile(`(?i)(\d+(?:[.,]\d+)?)G`)
|
||||||
|
rePortSpeed = regexp.MustCompile(`(?i)(\d+)p(\d+)(GbE|G)`)
|
||||||
|
rePortFC = regexp.MustCompile(`(?i)(\d+)pFC(\d+)`)
|
||||||
|
reWatts = regexp.MustCompile(`(?i)(\d{3,5})\s*W`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func Build(local *localdb.LocalDB, items []models.ConfigItem, opts BuildOptions) (BuildResult, error) {
|
||||||
|
segments := make([]string, 0, 8)
|
||||||
|
warnings := make([]string, 0)
|
||||||
|
|
||||||
|
model := NormalizeServerModel(opts.ServerModel)
|
||||||
|
if model == "" {
|
||||||
|
return BuildResult{}, fmt.Errorf("server_model required")
|
||||||
|
}
|
||||||
|
segments = append(segments, model)
|
||||||
|
|
||||||
|
lotNames := make([]string, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
lotNames = append(lotNames, it.LotName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.ServerPricelist == nil || *opts.ServerPricelist == 0 {
|
||||||
|
return BuildResult{}, fmt.Errorf("pricelist_id required for article")
|
||||||
|
}
|
||||||
|
|
||||||
|
cats, err := ResolveLotCategoriesStrict(local, *opts.ServerPricelist, lotNames)
|
||||||
|
if err != nil {
|
||||||
|
return BuildResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cpuSeg := buildCPUSegment(items, cats)
|
||||||
|
if cpuSeg != "" {
|
||||||
|
segments = append(segments, cpuSeg)
|
||||||
|
}
|
||||||
|
memSeg, memWarn := buildMemSegment(items, cats)
|
||||||
|
if memWarn != "" {
|
||||||
|
warnings = append(warnings, memWarn)
|
||||||
|
}
|
||||||
|
if memSeg != "" {
|
||||||
|
segments = append(segments, memSeg)
|
||||||
|
}
|
||||||
|
gpuSeg := buildGPUSegment(items, cats)
|
||||||
|
if gpuSeg != "" {
|
||||||
|
segments = append(segments, gpuSeg)
|
||||||
|
}
|
||||||
|
diskSeg, diskWarn := buildDiskSegment(items, cats)
|
||||||
|
if diskWarn != "" {
|
||||||
|
warnings = append(warnings, diskWarn)
|
||||||
|
}
|
||||||
|
if diskSeg != "" {
|
||||||
|
segments = append(segments, diskSeg)
|
||||||
|
}
|
||||||
|
netSeg, netWarn := buildNetSegment(items, cats)
|
||||||
|
if netWarn != "" {
|
||||||
|
warnings = append(warnings, netWarn)
|
||||||
|
}
|
||||||
|
if netSeg != "" {
|
||||||
|
segments = append(segments, netSeg)
|
||||||
|
}
|
||||||
|
psuSeg, psuWarn := buildPSUSegment(items, cats)
|
||||||
|
if psuWarn != "" {
|
||||||
|
warnings = append(warnings, psuWarn)
|
||||||
|
}
|
||||||
|
if psuSeg != "" {
|
||||||
|
segments = append(segments, psuSeg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(opts.SupportCode) != "" {
|
||||||
|
code := strings.TrimSpace(opts.SupportCode)
|
||||||
|
if !isSupportCodeValid(code) {
|
||||||
|
return BuildResult{}, fmt.Errorf("invalid_support_code")
|
||||||
|
}
|
||||||
|
segments = append(segments, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
article := strings.Join(segments, "-")
|
||||||
|
if len([]rune(article)) > 80 {
|
||||||
|
article = compressArticle(segments)
|
||||||
|
warnings = append(warnings, "compressed")
|
||||||
|
}
|
||||||
|
if len([]rune(article)) > 80 {
|
||||||
|
return BuildResult{}, fmt.Errorf("article_overflow")
|
||||||
|
}
|
||||||
|
|
||||||
|
return BuildResult{Article: article, Warnings: warnings}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSupportCodeValid(code string) bool {
|
||||||
|
if len(code) < 3 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !strings.Contains(code, "y") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
parts := strings.Split(code, "y")
|
||||||
|
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range parts[0] {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch parts[1] {
|
||||||
|
case "W", "B", "S", "P":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCPUSegment(items []models.ConfigItem, cats map[string]string) string {
|
||||||
|
type agg struct {
|
||||||
|
qty int
|
||||||
|
}
|
||||||
|
models := map[string]*agg{}
|
||||||
|
total := 0
|
||||||
|
for _, it := range items {
|
||||||
|
group, ok := GroupForLotCategory(cats[it.LotName])
|
||||||
|
if !ok || group != GroupCPU {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
model := parseCPUModel(it.LotName)
|
||||||
|
if model == "" {
|
||||||
|
model = "UNK"
|
||||||
|
}
|
||||||
|
if _, ok := models[model]; !ok {
|
||||||
|
models[model] = &agg{}
|
||||||
|
}
|
||||||
|
models[model].qty += it.Quantity
|
||||||
|
total += it.Quantity
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(models) == 1 {
|
||||||
|
for model, a := range models {
|
||||||
|
return fmt.Sprintf("%dx%s", a.qty, model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(models) <= 2 {
|
||||||
|
parts := make([]string, 0, len(models))
|
||||||
|
for model, a := range models {
|
||||||
|
parts = append(parts, fmt.Sprintf("%dx%s", a.qty, model))
|
||||||
|
}
|
||||||
|
sort.Strings(parts)
|
||||||
|
return strings.Join(parts, "+")
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%dxMIX", total)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildMemSegment(items []models.ConfigItem, cats map[string]string) (string, string) {
|
||||||
|
totalGiB := 0
|
||||||
|
for _, it := range items {
|
||||||
|
group, ok := GroupForLotCategory(cats[it.LotName])
|
||||||
|
if !ok || group != GroupMEM {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
per := parseMemGiB(it.LotName)
|
||||||
|
if per <= 0 {
|
||||||
|
return "", "mem_unknown"
|
||||||
|
}
|
||||||
|
totalGiB += per * it.Quantity
|
||||||
|
}
|
||||||
|
if totalGiB == 0 {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
if totalGiB%1024 == 0 {
|
||||||
|
return fmt.Sprintf("%dT", totalGiB/1024), ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%dG", totalGiB), ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildGPUSegment(items []models.ConfigItem, cats map[string]string) string {
|
||||||
|
models := map[string]int{}
|
||||||
|
total := 0
|
||||||
|
for _, it := range items {
|
||||||
|
group, ok := GroupForLotCategory(cats[it.LotName])
|
||||||
|
if !ok || group != GroupGPU {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
model := parseGPUModel(it.LotName)
|
||||||
|
if model == "" {
|
||||||
|
model = "UNK"
|
||||||
|
}
|
||||||
|
models[model] += it.Quantity
|
||||||
|
total += it.Quantity
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(models) <= 2 {
|
||||||
|
parts := make([]string, 0, len(models))
|
||||||
|
for model, qty := range models {
|
||||||
|
parts = append(parts, fmt.Sprintf("%dx%s", qty, model))
|
||||||
|
}
|
||||||
|
sort.Strings(parts)
|
||||||
|
return strings.Join(parts, "+")
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%dxMIX", total)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDiskSegment(items []models.ConfigItem, cats map[string]string) (string, string) {
|
||||||
|
type key struct {
|
||||||
|
t string
|
||||||
|
c string
|
||||||
|
}
|
||||||
|
groupQty := map[key]int{}
|
||||||
|
total := 0
|
||||||
|
warn := ""
|
||||||
|
for _, it := range items {
|
||||||
|
group, ok := GroupForLotCategory(cats[it.LotName])
|
||||||
|
if !ok || group != GroupDISK {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
capToken := parseCapacity(it.LotName)
|
||||||
|
if capToken == "" {
|
||||||
|
warn = "disk_unknown"
|
||||||
|
}
|
||||||
|
typeCode := diskTypeCode(cats[it.LotName], it.LotName)
|
||||||
|
k := key{t: typeCode, c: capToken}
|
||||||
|
groupQty[k] += it.Quantity
|
||||||
|
total += it.Quantity
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
parts := make([]string, 0, len(groupQty))
|
||||||
|
for k, qty := range groupQty {
|
||||||
|
if k.c == "" {
|
||||||
|
parts = append(parts, fmt.Sprintf("%dx%s", qty, k.t))
|
||||||
|
} else {
|
||||||
|
parts = append(parts, fmt.Sprintf("%dx%s%s", qty, k.c, k.t))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(parts)
|
||||||
|
if len(parts) > 2 {
|
||||||
|
return fmt.Sprintf("%dxMIXD", total), warn
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "+"), warn
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildNetSegment(items []models.ConfigItem, cats map[string]string) (string, string) {
|
||||||
|
groupQty := map[string]int{}
|
||||||
|
total := 0
|
||||||
|
warn := ""
|
||||||
|
for _, it := range items {
|
||||||
|
group, ok := GroupForLotCategory(cats[it.LotName])
|
||||||
|
if !ok || group != GroupNET {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
profile := parsePortSpeed(it.LotName)
|
||||||
|
if profile == "" {
|
||||||
|
profile = "UNKNET"
|
||||||
|
warn = "net_unknown"
|
||||||
|
}
|
||||||
|
groupQty[profile] += it.Quantity
|
||||||
|
total += it.Quantity
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
parts := make([]string, 0, len(groupQty))
|
||||||
|
for profile, qty := range groupQty {
|
||||||
|
parts = append(parts, fmt.Sprintf("%dx%s", qty, profile))
|
||||||
|
}
|
||||||
|
sort.Strings(parts)
|
||||||
|
if len(parts) > 2 {
|
||||||
|
return fmt.Sprintf("%dxMIXN", total), warn
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "+"), warn
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPSUSegment(items []models.ConfigItem, cats map[string]string) (string, string) {
|
||||||
|
groupQty := map[string]int{}
|
||||||
|
total := 0
|
||||||
|
warn := ""
|
||||||
|
for _, it := range items {
|
||||||
|
group, ok := GroupForLotCategory(cats[it.LotName])
|
||||||
|
if !ok || group != GroupPSU {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rating := parseWatts(it.LotName)
|
||||||
|
if rating == "" {
|
||||||
|
rating = "UNKPSU"
|
||||||
|
warn = "psu_unknown"
|
||||||
|
}
|
||||||
|
groupQty[rating] += it.Quantity
|
||||||
|
total += it.Quantity
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
parts := make([]string, 0, len(groupQty))
|
||||||
|
for rating, qty := range groupQty {
|
||||||
|
parts = append(parts, fmt.Sprintf("%dx%s", qty, rating))
|
||||||
|
}
|
||||||
|
sort.Strings(parts)
|
||||||
|
if len(parts) > 2 {
|
||||||
|
return fmt.Sprintf("%dxMIXP", total), warn
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "+"), warn
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeModelToken(lotName string) string {
|
||||||
|
if idx := strings.Index(lotName, "_"); idx >= 0 && idx+1 < len(lotName) {
|
||||||
|
lotName = lotName[idx+1:]
|
||||||
|
}
|
||||||
|
parts := strings.Split(lotName, "_")
|
||||||
|
token := parts[len(parts)-1]
|
||||||
|
return strings.ToUpper(strings.TrimSpace(token))
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCPUModel(lotName string) string {
|
||||||
|
parts := strings.Split(lotName, "_")
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
last := strings.ToUpper(strings.TrimSpace(parts[len(parts)-1]))
|
||||||
|
if last != "" {
|
||||||
|
return last
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalizeModelToken(lotName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseGPUModel(lotName string) string {
|
||||||
|
upper := strings.ToUpper(lotName)
|
||||||
|
if idx := strings.Index(upper, "GPU_"); idx >= 0 {
|
||||||
|
upper = upper[idx+4:]
|
||||||
|
}
|
||||||
|
parts := strings.Split(upper, "_")
|
||||||
|
model := ""
|
||||||
|
mem := ""
|
||||||
|
for i, p := range parts {
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch p {
|
||||||
|
case "NV", "NVIDIA", "AMD", "RADEON", "PCIE", "PCI", "SXM", "SXMX":
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
if strings.Contains(p, "GB") {
|
||||||
|
mem = p
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if model == "" && (i > 0) {
|
||||||
|
model = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if model != "" && mem != "" {
|
||||||
|
return model + "_" + mem
|
||||||
|
}
|
||||||
|
if model != "" {
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
return normalizeModelToken(lotName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMemGiB(lotName string) int {
|
||||||
|
if m := reMemTiB.FindStringSubmatch(lotName); len(m) == 3 {
|
||||||
|
return atoi(m[1]) * 1024
|
||||||
|
}
|
||||||
|
if m := reMemGiB.FindStringSubmatch(lotName); len(m) == 3 {
|
||||||
|
return atoi(m[1])
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCapacity(lotName string) string {
|
||||||
|
if m := reCapacityT.FindStringSubmatch(lotName); len(m) == 2 {
|
||||||
|
return normalizeTToken(strings.ReplaceAll(m[1], ",", ".")) + "T"
|
||||||
|
}
|
||||||
|
if m := reCapacityG.FindStringSubmatch(lotName); len(m) == 2 {
|
||||||
|
return normalizeNumberToken(strings.ReplaceAll(m[1], ",", ".")) + "G"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func diskTypeCode(cat string, lotName string) string {
|
||||||
|
c := strings.ToUpper(strings.TrimSpace(cat))
|
||||||
|
if c == "M2" {
|
||||||
|
return "M2"
|
||||||
|
}
|
||||||
|
upper := strings.ToUpper(lotName)
|
||||||
|
if strings.Contains(upper, "NVME") {
|
||||||
|
return "NV"
|
||||||
|
}
|
||||||
|
if strings.Contains(upper, "SAS") {
|
||||||
|
return "SAS"
|
||||||
|
}
|
||||||
|
if strings.Contains(upper, "SATA") {
|
||||||
|
return "SAT"
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePortSpeed(lotName string) string {
|
||||||
|
if m := rePortSpeed.FindStringSubmatch(lotName); len(m) == 4 {
|
||||||
|
return fmt.Sprintf("%sp%sG", m[1], m[2])
|
||||||
|
}
|
||||||
|
if m := rePortFC.FindStringSubmatch(lotName); len(m) == 3 {
|
||||||
|
return fmt.Sprintf("%spFC%s", m[1], m[2])
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseWatts(lotName string) string {
|
||||||
|
if m := reWatts.FindStringSubmatch(lotName); len(m) == 2 {
|
||||||
|
w := atoi(m[1])
|
||||||
|
if w >= 1000 {
|
||||||
|
kw := fmt.Sprintf("%.1f", float64(w)/1000.0)
|
||||||
|
kw = strings.TrimSuffix(kw, ".0")
|
||||||
|
return fmt.Sprintf("%skW", kw)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%dW", w)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeNumberToken(raw string) string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
raw = strings.TrimLeft(raw, "0")
|
||||||
|
if raw == "" || raw[0] == '.' {
|
||||||
|
raw = "0" + raw
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTToken(raw string) string {
|
||||||
|
raw = normalizeNumberToken(raw)
|
||||||
|
parts := strings.SplitN(raw, ".", 2)
|
||||||
|
intPart := parts[0]
|
||||||
|
frac := ""
|
||||||
|
if len(parts) == 2 {
|
||||||
|
frac = parts[1]
|
||||||
|
}
|
||||||
|
if frac == "" {
|
||||||
|
frac = "0"
|
||||||
|
}
|
||||||
|
if len(intPart) >= 2 {
|
||||||
|
return intPart + "." + frac
|
||||||
|
}
|
||||||
|
if len(frac) > 1 {
|
||||||
|
frac = frac[:1]
|
||||||
|
}
|
||||||
|
return intPart + "." + frac
|
||||||
|
}
|
||||||
|
|
||||||
|
func atoi(v string) int {
|
||||||
|
n := 0
|
||||||
|
for _, r := range v {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n = n*10 + int(r-'0')
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func compressArticle(segments []string) string {
|
||||||
|
if len(segments) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
normalized := make([]string, 0, len(segments))
|
||||||
|
for _, s := range segments {
|
||||||
|
normalized = append(normalized, strings.ReplaceAll(s, "GbE", "G"))
|
||||||
|
}
|
||||||
|
return strings.Join(normalized, "-")
|
||||||
|
}
|
||||||
66
internal/article/generator_test.go
Normal file
66
internal/article/generator_test.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package article
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuild_ParsesNetAndPSU(t *testing.T) {
|
||||||
|
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("init local db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = local.Close() })
|
||||||
|
|
||||||
|
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||||
|
ServerID: 1,
|
||||||
|
Source: "estimate",
|
||||||
|
Version: "S-2026-02-11-001",
|
||||||
|
Name: "test",
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
SyncedAt: time.Now(),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("save local pricelist: %v", err)
|
||||||
|
}
|
||||||
|
localPL, err := local.GetLocalPricelistByServerID(1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get local pricelist: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
||||||
|
{PricelistID: localPL.ID, LotName: "NIC_2p25G_MCX512A-AC", LotCategory: "NIC", Price: 1},
|
||||||
|
{PricelistID: localPL.ID, LotName: "HBA_2pFC32_Gen6", LotCategory: "HBA", Price: 1},
|
||||||
|
{PricelistID: localPL.ID, LotName: "PS_1000W_Platinum", LotCategory: "PS", Price: 1},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("save local items: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
items := models.ConfigItems{
|
||||||
|
{LotName: "NIC_2p25G_MCX512A-AC", Quantity: 1},
|
||||||
|
{LotName: "HBA_2pFC32_Gen6", Quantity: 1},
|
||||||
|
{LotName: "PS_1000W_Platinum", Quantity: 2},
|
||||||
|
}
|
||||||
|
result, err := Build(local, items, BuildOptions{
|
||||||
|
ServerModel: "DL380GEN11",
|
||||||
|
SupportCode: "1yW",
|
||||||
|
ServerPricelist: &localPL.ServerID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build article: %v", err)
|
||||||
|
}
|
||||||
|
if result.Article == "" {
|
||||||
|
t.Fatalf("expected article to be non-empty")
|
||||||
|
}
|
||||||
|
if contains(result.Article, "UNKNET") || contains(result.Article, "UNKPSU") {
|
||||||
|
t.Fatalf("unexpected UNK in article: %s", result.Article)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(s, sub string) bool {
|
||||||
|
return strings.Contains(s, sub)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/middleware"
|
"git.mchus.pro/mchus/quoteforge/internal/middleware"
|
||||||
@@ -35,6 +36,7 @@ type ExportRequest struct {
|
|||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
ProjectName string `json:"project_name"`
|
ProjectName string `json:"project_name"`
|
||||||
ProjectUUID string `json:"project_uuid"`
|
ProjectUUID string `json:"project_uuid"`
|
||||||
|
Article string `json:"article"`
|
||||||
Items []struct {
|
Items []struct {
|
||||||
LotName string `json:"lot_name" binding:"required"`
|
LotName string `json:"lot_name" binding:"required"`
|
||||||
Quantity int `json:"quantity" binding:"required,min=1"`
|
Quantity int `json:"quantity" binding:"required,min=1"`
|
||||||
@@ -73,7 +75,11 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
|||||||
|
|
||||||
// Set headers before streaming
|
// Set headers before streaming
|
||||||
exportDate := data.CreatedAt
|
exportDate := data.CreatedAt
|
||||||
filename := fmt.Sprintf("%s (%s) %s BOM.csv", exportDate.Format("2006-01-02"), projectName, req.Name)
|
articleSegment := sanitizeFilenameSegment(req.Article)
|
||||||
|
if articleSegment == "" {
|
||||||
|
articleSegment = "BOM"
|
||||||
|
}
|
||||||
|
filename := fmt.Sprintf("%s (%s) %s %s.csv", exportDate.Format("2006-01-02"), projectName, req.Name, articleSegment)
|
||||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||||
|
|
||||||
@@ -116,6 +122,7 @@ func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData
|
|||||||
|
|
||||||
return &services.ExportData{
|
return &services.ExportData{
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
|
Article: req.Article,
|
||||||
Items: items,
|
Items: items,
|
||||||
Total: total,
|
Total: total,
|
||||||
Notes: req.Notes,
|
Notes: req.Notes,
|
||||||
@@ -123,6 +130,24 @@ func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sanitizeFilenameSegment(value string) string {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
replacer := strings.NewReplacer(
|
||||||
|
"/", "_",
|
||||||
|
"\\", "_",
|
||||||
|
":", "_",
|
||||||
|
"*", "_",
|
||||||
|
"?", "_",
|
||||||
|
"\"", "_",
|
||||||
|
"<", "_",
|
||||||
|
">", "_",
|
||||||
|
"|", "_",
|
||||||
|
)
|
||||||
|
return strings.TrimSpace(replacer.Replace(value))
|
||||||
|
}
|
||||||
|
|
||||||
func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
|
func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
|
||||||
username := middleware.GetUsername(c)
|
username := middleware.GetUsername(c)
|
||||||
uuid := c.Param("uuid")
|
uuid := c.Param("uuid")
|
||||||
|
|||||||
@@ -157,15 +157,11 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
resultItems := make([]gin.H, 0, len(items))
|
resultItems := make([]gin.H, 0, len(items))
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
category := ""
|
|
||||||
if parts := strings.SplitN(item.LotName, "_", 2); len(parts) > 0 {
|
|
||||||
category = parts[0]
|
|
||||||
}
|
|
||||||
resultItems = append(resultItems, gin.H{
|
resultItems = append(resultItems, gin.H{
|
||||||
"id": item.ID,
|
"id": item.ID,
|
||||||
"lot_name": item.LotName,
|
"lot_name": item.LotName,
|
||||||
"price": item.Price,
|
"price": item.Price,
|
||||||
"category": category,
|
"category": item.LotCategory,
|
||||||
"available_qty": item.AvailableQty,
|
"available_qty": item.AvailableQty,
|
||||||
"partnumbers": []string(item.Partnumbers),
|
"partnumbers": []string(item.Partnumbers),
|
||||||
})
|
})
|
||||||
|
|||||||
84
internal/handlers/pricelist_test.go
Normal file
84
internal/handlers/pricelist_test.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPricelistGetItems_ReturnsLotCategoryFromLocalPricelistItems(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("init local db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = local.Close() })
|
||||||
|
|
||||||
|
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||||
|
ServerID: 1,
|
||||||
|
Source: "estimate",
|
||||||
|
Version: "S-2026-02-11-001",
|
||||||
|
Name: "test",
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
SyncedAt: time.Now(),
|
||||||
|
IsUsed: false,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("save local pricelist: %v", err)
|
||||||
|
}
|
||||||
|
localPL, err := local.GetLocalPricelistByServerID(1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get local pricelist: %v", err)
|
||||||
|
}
|
||||||
|
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
||||||
|
{
|
||||||
|
PricelistID: localPL.ID,
|
||||||
|
LotName: "NO_UNDERSCORE_NAME",
|
||||||
|
LotCategory: "CPU",
|
||||||
|
Price: 10,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("save local pricelist items: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewPricelistHandler(local)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", "/api/pricelists/1/items?page=1&per_page=50", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = req
|
||||||
|
c.Params = gin.Params{{Key: "id", Value: "1"}}
|
||||||
|
|
||||||
|
h.GetItems(c)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Items []struct {
|
||||||
|
LotName string `json:"lot_name"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
UnitPrice any `json:"price"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Items) != 1 {
|
||||||
|
t.Fatalf("expected 1 item, got %d", len(resp.Items))
|
||||||
|
}
|
||||||
|
if resp.Items[0].LotName != "NO_UNDERSCORE_NAME" {
|
||||||
|
t.Fatalf("expected lot_name NO_UNDERSCORE_NAME, got %q", resp.Items[0].LotName)
|
||||||
|
}
|
||||||
|
if resp.Items[0].Category != "CPU" {
|
||||||
|
t.Fatalf("expected category CPU, got %q", resp.Items[0].Category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -28,6 +28,9 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
|
|||||||
Notes: cfg.Notes,
|
Notes: cfg.Notes,
|
||||||
IsTemplate: cfg.IsTemplate,
|
IsTemplate: cfg.IsTemplate,
|
||||||
ServerCount: cfg.ServerCount,
|
ServerCount: cfg.ServerCount,
|
||||||
|
ServerModel: cfg.ServerModel,
|
||||||
|
SupportCode: cfg.SupportCode,
|
||||||
|
Article: cfg.Article,
|
||||||
PricelistID: cfg.PricelistID,
|
PricelistID: cfg.PricelistID,
|
||||||
OnlyInStock: cfg.OnlyInStock,
|
OnlyInStock: cfg.OnlyInStock,
|
||||||
PriceUpdatedAt: cfg.PriceUpdatedAt,
|
PriceUpdatedAt: cfg.PriceUpdatedAt,
|
||||||
@@ -72,6 +75,9 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
|
|||||||
Notes: local.Notes,
|
Notes: local.Notes,
|
||||||
IsTemplate: local.IsTemplate,
|
IsTemplate: local.IsTemplate,
|
||||||
ServerCount: local.ServerCount,
|
ServerCount: local.ServerCount,
|
||||||
|
ServerModel: local.ServerModel,
|
||||||
|
SupportCode: local.SupportCode,
|
||||||
|
Article: local.Article,
|
||||||
PricelistID: local.PricelistID,
|
PricelistID: local.PricelistID,
|
||||||
OnlyInStock: local.OnlyInStock,
|
OnlyInStock: local.OnlyInStock,
|
||||||
PriceUpdatedAt: local.PriceUpdatedAt,
|
PriceUpdatedAt: local.PriceUpdatedAt,
|
||||||
@@ -169,6 +175,7 @@ func PricelistItemToLocal(item *models.PricelistItem, localPricelistID uint) *Lo
|
|||||||
return &LocalPricelistItem{
|
return &LocalPricelistItem{
|
||||||
PricelistID: localPricelistID,
|
PricelistID: localPricelistID,
|
||||||
LotName: item.LotName,
|
LotName: item.LotName,
|
||||||
|
LotCategory: item.LotCategory,
|
||||||
Price: item.Price,
|
Price: item.Price,
|
||||||
AvailableQty: item.AvailableQty,
|
AvailableQty: item.AvailableQty,
|
||||||
Partnumbers: partnumbers,
|
Partnumbers: partnumbers,
|
||||||
@@ -183,6 +190,7 @@ func LocalToPricelistItem(local *LocalPricelistItem, serverPricelistID uint) *mo
|
|||||||
ID: local.ID,
|
ID: local.ID,
|
||||||
PricelistID: serverPricelistID,
|
PricelistID: serverPricelistID,
|
||||||
LotName: local.LotName,
|
LotName: local.LotName,
|
||||||
|
LotCategory: local.LotCategory,
|
||||||
Price: local.Price,
|
Price: local.Price,
|
||||||
AvailableQty: local.AvailableQty,
|
AvailableQty: local.AvailableQty,
|
||||||
Partnumbers: partnumbers,
|
Partnumbers: partnumbers,
|
||||||
|
|||||||
34
internal/localdb/converters_test.go
Normal file
34
internal/localdb/converters_test.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package localdb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPricelistItemToLocal_PreservesLotCategory(t *testing.T) {
|
||||||
|
item := &models.PricelistItem{
|
||||||
|
LotName: "CPU_A",
|
||||||
|
LotCategory: "CPU",
|
||||||
|
Price: 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
local := PricelistItemToLocal(item, 123)
|
||||||
|
if local.LotCategory != "CPU" {
|
||||||
|
t.Fatalf("expected LotCategory=CPU, got %q", local.LotCategory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalToPricelistItem_PreservesLotCategory(t *testing.T) {
|
||||||
|
local := &LocalPricelistItem{
|
||||||
|
LotName: "CPU_A",
|
||||||
|
LotCategory: "CPU",
|
||||||
|
Price: 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
item := LocalToPricelistItem(local, 456)
|
||||||
|
if item.LotCategory != "CPU" {
|
||||||
|
t.Fatalf("expected LotCategory=CPU, got %q", item.LotCategory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -645,6 +645,17 @@ func (l *LocalDB) CountLocalPricelistItems(pricelistID uint) int64 {
|
|||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountLocalPricelistItemsWithEmptyCategory returns the number of items for a pricelist with missing lot_category.
|
||||||
|
func (l *LocalDB) CountLocalPricelistItemsWithEmptyCategory(pricelistID uint) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
if err := l.db.Model(&LocalPricelistItem{}).
|
||||||
|
Where("pricelist_id = ? AND (lot_category IS NULL OR TRIM(lot_category) = '')", pricelistID).
|
||||||
|
Count(&count).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
// SaveLocalPricelistItems saves pricelist items to local SQLite
|
// SaveLocalPricelistItems saves pricelist items to local SQLite
|
||||||
func (l *LocalDB) SaveLocalPricelistItems(items []LocalPricelistItem) error {
|
func (l *LocalDB) SaveLocalPricelistItems(items []LocalPricelistItem) error {
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
@@ -665,6 +676,30 @@ func (l *LocalDB) SaveLocalPricelistItems(items []LocalPricelistItem) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReplaceLocalPricelistItems atomically replaces all items for a pricelist.
|
||||||
|
func (l *LocalDB) ReplaceLocalPricelistItems(pricelistID uint, items []LocalPricelistItem) error {
|
||||||
|
return l.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Where("pricelist_id = ?", pricelistID).Delete(&LocalPricelistItem{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
batchSize := 500
|
||||||
|
for i := 0; i < len(items); i += batchSize {
|
||||||
|
end := i + batchSize
|
||||||
|
if end > len(items) {
|
||||||
|
end = len(items)
|
||||||
|
}
|
||||||
|
if err := tx.CreateInBatches(items[i:end], batchSize).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// GetLocalPricelistItems returns items for a local pricelist
|
// GetLocalPricelistItems returns items for a local pricelist
|
||||||
func (l *LocalDB) GetLocalPricelistItems(pricelistID uint) ([]LocalPricelistItem, error) {
|
func (l *LocalDB) GetLocalPricelistItems(pricelistID uint) ([]LocalPricelistItem, error) {
|
||||||
var items []LocalPricelistItem
|
var items []LocalPricelistItem
|
||||||
@@ -684,6 +719,36 @@ func (l *LocalDB) GetLocalPriceForLot(pricelistID uint, lotName string) (float64
|
|||||||
return item.Price, nil
|
return item.Price, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetLocalLotCategoriesByServerPricelistID returns lot_category for each lot_name from a local pricelist resolved by server ID.
|
||||||
|
// Missing lots are not included in the map; caller is responsible for strict validation.
|
||||||
|
func (l *LocalDB) GetLocalLotCategoriesByServerPricelistID(serverPricelistID uint, lotNames []string) (map[string]string, error) {
|
||||||
|
result := make(map[string]string, len(lotNames))
|
||||||
|
if serverPricelistID == 0 || len(lotNames) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
localPL, err := l.GetLocalPricelistByServerID(serverPricelistID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
type row struct {
|
||||||
|
LotName string `gorm:"column:lot_name"`
|
||||||
|
LotCategory string `gorm:"column:lot_category"`
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
if err := l.db.Model(&LocalPricelistItem{}).
|
||||||
|
Select("lot_name, lot_category").
|
||||||
|
Where("pricelist_id = ? AND lot_name IN ?", localPL.ID, lotNames).
|
||||||
|
Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
result[r.LotName] = r.LotCategory
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
// MarkPricelistAsUsed marks a pricelist as used by a configuration
|
// MarkPricelistAsUsed marks a pricelist as used by a configuration
|
||||||
func (l *LocalDB) MarkPricelistAsUsed(pricelistID uint, isUsed bool) error {
|
func (l *LocalDB) MarkPricelistAsUsed(pricelistID uint, isUsed bool) error {
|
||||||
return l.db.Model(&LocalPricelist{}).Where("id = ?", pricelistID).
|
return l.db.Model(&LocalPricelist{}).Where("id = ?", pricelistID).
|
||||||
|
|||||||
@@ -68,6 +68,26 @@ var localMigrations = []localMigration{
|
|||||||
name: "Add warehouse_pricelist_id and competitor_pricelist_id to local_configurations",
|
name: "Add warehouse_pricelist_id and competitor_pricelist_id to local_configurations",
|
||||||
run: addWarehouseCompetitorPriceLists,
|
run: addWarehouseCompetitorPriceLists,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "2026_02_11_local_pricelist_item_category",
|
||||||
|
name: "Add lot_category to local_pricelist_items and create indexes",
|
||||||
|
run: addLocalPricelistItemCategoryAndIndexes,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2026_02_11_local_config_article",
|
||||||
|
name: "Add article to local_configurations",
|
||||||
|
run: addLocalConfigurationArticle,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2026_02_11_local_config_server_model",
|
||||||
|
name: "Add server_model to local_configurations",
|
||||||
|
run: addLocalConfigurationServerModel,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2026_02_11_local_config_support_code",
|
||||||
|
name: "Add support_code to local_configurations",
|
||||||
|
run: addLocalConfigurationSupportCode,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func runLocalMigrations(db *gorm.DB) error {
|
func runLocalMigrations(db *gorm.DB) error {
|
||||||
@@ -436,3 +456,112 @@ func addWarehouseCompetitorPriceLists(tx *gorm.DB) error {
|
|||||||
slog.Info("added warehouse and competitor pricelist fields to local_configurations")
|
slog.Info("added warehouse and competitor pricelist fields to local_configurations")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func addLocalPricelistItemCategoryAndIndexes(tx *gorm.DB) error {
|
||||||
|
type columnInfo struct {
|
||||||
|
Name string `gorm:"column:name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var columns []columnInfo
|
||||||
|
if err := tx.Raw(`
|
||||||
|
SELECT name FROM pragma_table_info('local_pricelist_items')
|
||||||
|
WHERE name IN ('lot_category')
|
||||||
|
`).Scan(&columns).Error; err != nil {
|
||||||
|
return fmt.Errorf("check local_pricelist_items(lot_category) existence: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(columns) == 0 {
|
||||||
|
if err := tx.Exec(`
|
||||||
|
ALTER TABLE local_pricelist_items
|
||||||
|
ADD COLUMN lot_category TEXT
|
||||||
|
`).Error; err != nil {
|
||||||
|
return fmt.Errorf("add local_pricelist_items.lot_category: %w", err)
|
||||||
|
}
|
||||||
|
slog.Info("added lot_category to local_pricelist_items")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_local_pricelist_items_pricelist_lot
|
||||||
|
ON local_pricelist_items(pricelist_id, lot_name)
|
||||||
|
`).Error; err != nil {
|
||||||
|
return fmt.Errorf("ensure idx_local_pricelist_items_pricelist_lot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_local_pricelist_items_lot_category
|
||||||
|
ON local_pricelist_items(lot_category)
|
||||||
|
`).Error; err != nil {
|
||||||
|
return fmt.Errorf("ensure idx_local_pricelist_items_lot_category: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func addLocalConfigurationArticle(tx *gorm.DB) error {
|
||||||
|
type columnInfo struct {
|
||||||
|
Name string `gorm:"column:name"`
|
||||||
|
}
|
||||||
|
var columns []columnInfo
|
||||||
|
if err := tx.Raw(`
|
||||||
|
SELECT name FROM pragma_table_info('local_configurations')
|
||||||
|
WHERE name IN ('article')
|
||||||
|
`).Scan(&columns).Error; err != nil {
|
||||||
|
return fmt.Errorf("check local_configurations(article) existence: %w", err)
|
||||||
|
}
|
||||||
|
if len(columns) == 0 {
|
||||||
|
if err := tx.Exec(`
|
||||||
|
ALTER TABLE local_configurations
|
||||||
|
ADD COLUMN article TEXT
|
||||||
|
`).Error; err != nil {
|
||||||
|
return fmt.Errorf("add local_configurations.article: %w", err)
|
||||||
|
}
|
||||||
|
slog.Info("added article to local_configurations")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func addLocalConfigurationServerModel(tx *gorm.DB) error {
|
||||||
|
type columnInfo struct {
|
||||||
|
Name string `gorm:"column:name"`
|
||||||
|
}
|
||||||
|
var columns []columnInfo
|
||||||
|
if err := tx.Raw(`
|
||||||
|
SELECT name FROM pragma_table_info('local_configurations')
|
||||||
|
WHERE name IN ('server_model')
|
||||||
|
`).Scan(&columns).Error; err != nil {
|
||||||
|
return fmt.Errorf("check local_configurations(server_model) existence: %w", err)
|
||||||
|
}
|
||||||
|
if len(columns) == 0 {
|
||||||
|
if err := tx.Exec(`
|
||||||
|
ALTER TABLE local_configurations
|
||||||
|
ADD COLUMN server_model TEXT
|
||||||
|
`).Error; err != nil {
|
||||||
|
return fmt.Errorf("add local_configurations.server_model: %w", err)
|
||||||
|
}
|
||||||
|
slog.Info("added server_model to local_configurations")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func addLocalConfigurationSupportCode(tx *gorm.DB) error {
|
||||||
|
type columnInfo struct {
|
||||||
|
Name string `gorm:"column:name"`
|
||||||
|
}
|
||||||
|
var columns []columnInfo
|
||||||
|
if err := tx.Raw(`
|
||||||
|
SELECT name FROM pragma_table_info('local_configurations')
|
||||||
|
WHERE name IN ('support_code')
|
||||||
|
`).Scan(&columns).Error; err != nil {
|
||||||
|
return fmt.Errorf("check local_configurations(support_code) existence: %w", err)
|
||||||
|
}
|
||||||
|
if len(columns) == 0 {
|
||||||
|
if err := tx.Exec(`
|
||||||
|
ALTER TABLE local_configurations
|
||||||
|
ADD COLUMN support_code TEXT
|
||||||
|
`).Error; err != nil {
|
||||||
|
return fmt.Errorf("add local_configurations.support_code: %w", err)
|
||||||
|
}
|
||||||
|
slog.Info("added support_code to local_configurations")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ type LocalConfiguration struct {
|
|||||||
Notes string `json:"notes"`
|
Notes string `json:"notes"`
|
||||||
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
||||||
ServerCount int `gorm:"default:1" json:"server_count"`
|
ServerCount int `gorm:"default:1" json:"server_count"`
|
||||||
|
ServerModel string `gorm:"size:100" json:"server_model,omitempty"`
|
||||||
|
SupportCode string `gorm:"size:20" json:"support_code,omitempty"`
|
||||||
|
Article string `gorm:"size:80" json:"article,omitempty"`
|
||||||
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
|
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
|
||||||
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
|
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
|
||||||
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
|
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
|
||||||
@@ -172,6 +175,7 @@ type LocalPricelistItem struct {
|
|||||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
PricelistID uint `gorm:"not null;index" json:"pricelist_id"`
|
PricelistID uint `gorm:"not null;index" json:"pricelist_id"`
|
||||||
LotName string `gorm:"not null" json:"lot_name"`
|
LotName string `gorm:"not null" json:"lot_name"`
|
||||||
|
LotCategory string `gorm:"column:lot_category" json:"lot_category,omitempty"`
|
||||||
Price float64 `gorm:"not null" json:"price"`
|
Price float64 `gorm:"not null" json:"price"`
|
||||||
AvailableQty *float64 `json:"available_qty,omitempty"`
|
AvailableQty *float64 `json:"available_qty,omitempty"`
|
||||||
Partnumbers LocalStringList `gorm:"type:text" json:"partnumbers,omitempty"`
|
Partnumbers LocalStringList `gorm:"type:text" json:"partnumbers,omitempty"`
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
|
|||||||
"notes": localCfg.Notes,
|
"notes": localCfg.Notes,
|
||||||
"is_template": localCfg.IsTemplate,
|
"is_template": localCfg.IsTemplate,
|
||||||
"server_count": localCfg.ServerCount,
|
"server_count": localCfg.ServerCount,
|
||||||
|
"server_model": localCfg.ServerModel,
|
||||||
|
"support_code": localCfg.SupportCode,
|
||||||
|
"article": localCfg.Article,
|
||||||
"pricelist_id": localCfg.PricelistID,
|
"pricelist_id": localCfg.PricelistID,
|
||||||
"only_in_stock": localCfg.OnlyInStock,
|
"only_in_stock": localCfg.OnlyInStock,
|
||||||
"price_updated_at": localCfg.PriceUpdatedAt,
|
"price_updated_at": localCfg.PriceUpdatedAt,
|
||||||
@@ -52,6 +55,9 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
|
|||||||
Notes string `json:"notes"`
|
Notes string `json:"notes"`
|
||||||
IsTemplate bool `json:"is_template"`
|
IsTemplate bool `json:"is_template"`
|
||||||
ServerCount int `json:"server_count"`
|
ServerCount int `json:"server_count"`
|
||||||
|
ServerModel string `json:"server_model"`
|
||||||
|
SupportCode string `json:"support_code"`
|
||||||
|
Article string `json:"article"`
|
||||||
PricelistID *uint `json:"pricelist_id"`
|
PricelistID *uint `json:"pricelist_id"`
|
||||||
OnlyInStock bool `json:"only_in_stock"`
|
OnlyInStock bool `json:"only_in_stock"`
|
||||||
PriceUpdatedAt *time.Time `json:"price_updated_at"`
|
PriceUpdatedAt *time.Time `json:"price_updated_at"`
|
||||||
@@ -78,6 +84,9 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
|
|||||||
Notes: snapshot.Notes,
|
Notes: snapshot.Notes,
|
||||||
IsTemplate: snapshot.IsTemplate,
|
IsTemplate: snapshot.IsTemplate,
|
||||||
ServerCount: snapshot.ServerCount,
|
ServerCount: snapshot.ServerCount,
|
||||||
|
ServerModel: snapshot.ServerModel,
|
||||||
|
SupportCode: snapshot.SupportCode,
|
||||||
|
Article: snapshot.Article,
|
||||||
PricelistID: snapshot.PricelistID,
|
PricelistID: snapshot.PricelistID,
|
||||||
OnlyInStock: snapshot.OnlyInStock,
|
OnlyInStock: snapshot.OnlyInStock,
|
||||||
PriceUpdatedAt: snapshot.PriceUpdatedAt,
|
PriceUpdatedAt: snapshot.PriceUpdatedAt,
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ type Configuration struct {
|
|||||||
Notes string `gorm:"type:text" json:"notes"`
|
Notes string `gorm:"type:text" json:"notes"`
|
||||||
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
||||||
ServerCount int `gorm:"default:1" json:"server_count"`
|
ServerCount int `gorm:"default:1" json:"server_count"`
|
||||||
|
ServerModel string `gorm:"size:100" json:"server_model,omitempty"`
|
||||||
|
SupportCode string `gorm:"size:20" json:"support_code,omitempty"`
|
||||||
|
Article string `gorm:"size:80" json:"article,omitempty"`
|
||||||
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
|
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
|
||||||
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
|
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
|
||||||
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
|
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ type PricelistItem struct {
|
|||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
PricelistID uint `gorm:"not null;index:idx_pricelist_lot" json:"pricelist_id"`
|
PricelistID uint `gorm:"not null;index:idx_pricelist_lot" json:"pricelist_id"`
|
||||||
LotName string `gorm:"size:255;not null;index:idx_pricelist_lot" json:"lot_name"`
|
LotName string `gorm:"size:255;not null;index:idx_pricelist_lot" json:"lot_name"`
|
||||||
|
LotCategory string `gorm:"column:lot_category;size:50" json:"lot_category,omitempty"`
|
||||||
Price float64 `gorm:"type:decimal(12,2);not null" json:"price"`
|
Price float64 `gorm:"type:decimal(12,2);not null" json:"price"`
|
||||||
PriceMethod string `gorm:"size:20" json:"price_method"`
|
PriceMethod string `gorm:"size:20" json:"price_method"`
|
||||||
|
|
||||||
|
|||||||
@@ -238,11 +238,7 @@ func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, sear
|
|||||||
if err := r.db.Where("lot_name = ?", items[i].LotName).First(&lot).Error; err == nil {
|
if err := r.db.Where("lot_name = ?", items[i].LotName).First(&lot).Error; err == nil {
|
||||||
items[i].LotDescription = lot.LotDescription
|
items[i].LotDescription = lot.LotDescription
|
||||||
}
|
}
|
||||||
// Parse category from lot_name (e.g., "CPU_AMD_9654" -> "CPU")
|
items[i].Category = strings.TrimSpace(items[i].LotCategory)
|
||||||
parts := strings.SplitN(items[i].LotName, "_", 2)
|
|
||||||
if len(parts) >= 1 {
|
|
||||||
items[i].Category = parts[0]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := r.enrichItemsWithStock(items); err != nil {
|
if err := r.enrichItemsWithStock(items); err != nil {
|
||||||
|
|||||||
@@ -52,10 +52,20 @@ type CreateConfigRequest struct {
|
|||||||
Notes string `json:"notes"`
|
Notes string `json:"notes"`
|
||||||
IsTemplate bool `json:"is_template"`
|
IsTemplate bool `json:"is_template"`
|
||||||
ServerCount int `json:"server_count"`
|
ServerCount int `json:"server_count"`
|
||||||
|
ServerModel string `json:"server_model,omitempty"`
|
||||||
|
SupportCode string `json:"support_code,omitempty"`
|
||||||
|
Article string `json:"article,omitempty"`
|
||||||
PricelistID *uint `json:"pricelist_id,omitempty"`
|
PricelistID *uint `json:"pricelist_id,omitempty"`
|
||||||
OnlyInStock bool `json:"only_in_stock"`
|
OnlyInStock bool `json:"only_in_stock"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ArticlePreviewRequest struct {
|
||||||
|
Items models.ConfigItems `json:"items"`
|
||||||
|
ServerModel string `json:"server_model"`
|
||||||
|
SupportCode string `json:"support_code,omitempty"`
|
||||||
|
PricelistID *uint `json:"pricelist_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
|
func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||||
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
|
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -84,6 +94,9 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq
|
|||||||
Notes: req.Notes,
|
Notes: req.Notes,
|
||||||
IsTemplate: req.IsTemplate,
|
IsTemplate: req.IsTemplate,
|
||||||
ServerCount: req.ServerCount,
|
ServerCount: req.ServerCount,
|
||||||
|
ServerModel: req.ServerModel,
|
||||||
|
SupportCode: req.SupportCode,
|
||||||
|
Article: req.Article,
|
||||||
PricelistID: pricelistID,
|
PricelistID: pricelistID,
|
||||||
OnlyInStock: req.OnlyInStock,
|
OnlyInStock: req.OnlyInStock,
|
||||||
}
|
}
|
||||||
@@ -146,6 +159,9 @@ func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *Cr
|
|||||||
config.Notes = req.Notes
|
config.Notes = req.Notes
|
||||||
config.IsTemplate = req.IsTemplate
|
config.IsTemplate = req.IsTemplate
|
||||||
config.ServerCount = req.ServerCount
|
config.ServerCount = req.ServerCount
|
||||||
|
config.ServerModel = req.ServerModel
|
||||||
|
config.SupportCode = req.SupportCode
|
||||||
|
config.Article = req.Article
|
||||||
config.PricelistID = pricelistID
|
config.PricelistID = pricelistID
|
||||||
config.OnlyInStock = req.OnlyInStock
|
config.OnlyInStock = req.OnlyInStock
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ func NewExportService(cfg config.ExportConfig, categoryRepo *repository.Category
|
|||||||
|
|
||||||
type ExportData struct {
|
type ExportData struct {
|
||||||
Name string
|
Name string
|
||||||
|
Article string
|
||||||
Items []ExportItem
|
Items []ExportItem
|
||||||
Total float64
|
Total float64
|
||||||
Notes string
|
Notes string
|
||||||
@@ -109,7 +110,7 @@ func (s *ExportService) ToCSV(w io.Writer, data *ExportData) error {
|
|||||||
|
|
||||||
// Total row
|
// Total row
|
||||||
totalStr := strings.ReplaceAll(fmt.Sprintf("%.2f", data.Total), ".", ",")
|
totalStr := strings.ReplaceAll(fmt.Sprintf("%.2f", data.Total), ".", ",")
|
||||||
if err := csvWriter.Write([]string{"", "", "", "", "ИТОГО:", totalStr}); err != nil {
|
if err := csvWriter.Write([]string{data.Article, "", "", "", "ИТОГО:", totalStr}); err != nil {
|
||||||
return fmt.Errorf("failed to write total row: %w", err)
|
return fmt.Errorf("failed to write total row: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +163,7 @@ func (s *ExportService) ConfigToExportData(config *models.Configuration, compone
|
|||||||
|
|
||||||
return &ExportData{
|
return &ExportData{
|
||||||
Name: config.Name,
|
Name: config.Name,
|
||||||
|
Article: "",
|
||||||
Items: items,
|
Items: items,
|
||||||
Total: total,
|
Total: total,
|
||||||
Notes: config.Notes,
|
Notes: config.Notes,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/article"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||||
@@ -64,6 +65,18 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(req.ServerModel) != "" {
|
||||||
|
articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{
|
||||||
|
ServerModel: req.ServerModel,
|
||||||
|
SupportCode: req.SupportCode,
|
||||||
|
ServerPricelist: pricelistID,
|
||||||
|
})
|
||||||
|
if articleErr != nil {
|
||||||
|
return nil, articleErr
|
||||||
|
}
|
||||||
|
req.Article = articleResult.Article
|
||||||
|
}
|
||||||
|
|
||||||
total := req.Items.Total()
|
total := req.Items.Total()
|
||||||
if req.ServerCount > 1 {
|
if req.ServerCount > 1 {
|
||||||
total *= float64(req.ServerCount)
|
total *= float64(req.ServerCount)
|
||||||
@@ -80,6 +93,9 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
|
|||||||
Notes: req.Notes,
|
Notes: req.Notes,
|
||||||
IsTemplate: req.IsTemplate,
|
IsTemplate: req.IsTemplate,
|
||||||
ServerCount: req.ServerCount,
|
ServerCount: req.ServerCount,
|
||||||
|
ServerModel: req.ServerModel,
|
||||||
|
SupportCode: req.SupportCode,
|
||||||
|
Article: req.Article,
|
||||||
PricelistID: pricelistID,
|
PricelistID: pricelistID,
|
||||||
OnlyInStock: req.OnlyInStock,
|
OnlyInStock: req.OnlyInStock,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
@@ -142,6 +158,18 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(req.ServerModel) != "" {
|
||||||
|
articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{
|
||||||
|
ServerModel: req.ServerModel,
|
||||||
|
SupportCode: req.SupportCode,
|
||||||
|
ServerPricelist: pricelistID,
|
||||||
|
})
|
||||||
|
if articleErr != nil {
|
||||||
|
return nil, articleErr
|
||||||
|
}
|
||||||
|
req.Article = articleResult.Article
|
||||||
|
}
|
||||||
|
|
||||||
total := req.Items.Total()
|
total := req.Items.Total()
|
||||||
if req.ServerCount > 1 {
|
if req.ServerCount > 1 {
|
||||||
total *= float64(req.ServerCount)
|
total *= float64(req.ServerCount)
|
||||||
@@ -163,6 +191,9 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
|
|||||||
localCfg.Notes = req.Notes
|
localCfg.Notes = req.Notes
|
||||||
localCfg.IsTemplate = req.IsTemplate
|
localCfg.IsTemplate = req.IsTemplate
|
||||||
localCfg.ServerCount = req.ServerCount
|
localCfg.ServerCount = req.ServerCount
|
||||||
|
localCfg.ServerModel = req.ServerModel
|
||||||
|
localCfg.SupportCode = req.SupportCode
|
||||||
|
localCfg.Article = req.Article
|
||||||
localCfg.PricelistID = pricelistID
|
localCfg.PricelistID = pricelistID
|
||||||
localCfg.OnlyInStock = req.OnlyInStock
|
localCfg.OnlyInStock = req.OnlyInStock
|
||||||
localCfg.UpdatedAt = time.Now()
|
localCfg.UpdatedAt = time.Now()
|
||||||
@@ -176,6 +207,19 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BuildArticlePreview generates server article based on current items and server_model/support_code.
|
||||||
|
func (s *LocalConfigurationService) BuildArticlePreview(req *ArticlePreviewRequest) (article.BuildResult, error) {
|
||||||
|
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
||||||
|
if err != nil {
|
||||||
|
return article.BuildResult{}, err
|
||||||
|
}
|
||||||
|
return article.Build(s.localDB, req.Items, article.BuildOptions{
|
||||||
|
ServerModel: req.ServerModel,
|
||||||
|
SupportCode: req.SupportCode,
|
||||||
|
ServerPricelist: pricelistID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Delete deletes a configuration from local SQLite and queues it for sync
|
// Delete deletes a configuration from local SQLite and queues it for sync
|
||||||
func (s *LocalConfigurationService) Delete(uuid string, ownerUsername string) error {
|
func (s *LocalConfigurationService) Delete(uuid string, ownerUsername string) error {
|
||||||
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||||
@@ -269,6 +313,9 @@ func (s *LocalConfigurationService) CloneToProject(configUUID string, ownerUsern
|
|||||||
Notes: original.Notes,
|
Notes: original.Notes,
|
||||||
IsTemplate: false,
|
IsTemplate: false,
|
||||||
ServerCount: original.ServerCount,
|
ServerCount: original.ServerCount,
|
||||||
|
ServerModel: original.ServerModel,
|
||||||
|
SupportCode: original.SupportCode,
|
||||||
|
Article: original.Article,
|
||||||
PricelistID: original.PricelistID,
|
PricelistID: original.PricelistID,
|
||||||
OnlyInStock: original.OnlyInStock,
|
OnlyInStock: original.OnlyInStock,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
@@ -424,6 +471,18 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(req.ServerModel) != "" {
|
||||||
|
articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{
|
||||||
|
ServerModel: req.ServerModel,
|
||||||
|
SupportCode: req.SupportCode,
|
||||||
|
ServerPricelist: pricelistID,
|
||||||
|
})
|
||||||
|
if articleErr != nil {
|
||||||
|
return nil, articleErr
|
||||||
|
}
|
||||||
|
req.Article = articleResult.Article
|
||||||
|
}
|
||||||
|
|
||||||
total := req.Items.Total()
|
total := req.Items.Total()
|
||||||
if req.ServerCount > 1 {
|
if req.ServerCount > 1 {
|
||||||
total *= float64(req.ServerCount)
|
total *= float64(req.ServerCount)
|
||||||
@@ -444,6 +503,9 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
|
|||||||
localCfg.Notes = req.Notes
|
localCfg.Notes = req.Notes
|
||||||
localCfg.IsTemplate = req.IsTemplate
|
localCfg.IsTemplate = req.IsTemplate
|
||||||
localCfg.ServerCount = req.ServerCount
|
localCfg.ServerCount = req.ServerCount
|
||||||
|
localCfg.ServerModel = req.ServerModel
|
||||||
|
localCfg.SupportCode = req.SupportCode
|
||||||
|
localCfg.Article = req.Article
|
||||||
localCfg.PricelistID = pricelistID
|
localCfg.PricelistID = pricelistID
|
||||||
localCfg.OnlyInStock = req.OnlyInStock
|
localCfg.OnlyInStock = req.OnlyInStock
|
||||||
localCfg.UpdatedAt = time.Now()
|
localCfg.UpdatedAt = time.Now()
|
||||||
|
|||||||
@@ -388,6 +388,9 @@ func (s *Service) SyncPricelists() (int, error) {
|
|||||||
slog.Info("deleted stale local pricelists", "deleted", removed)
|
slog.Info("deleted stale local pricelists", "deleted", removed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backfill lot_category for used pricelists (older local caches may miss the column values).
|
||||||
|
s.backfillUsedPricelistItemCategories(pricelistRepo, serverPricelistIDs)
|
||||||
|
|
||||||
// Update last sync time
|
// Update last sync time
|
||||||
s.localDB.SetLastSyncTime(time.Now())
|
s.localDB.SetLastSyncTime(time.Now())
|
||||||
s.RecordSyncHeartbeat()
|
s.RecordSyncHeartbeat()
|
||||||
@@ -396,6 +399,83 @@ func (s *Service) SyncPricelists() (int, error) {
|
|||||||
return synced, nil
|
return synced, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) backfillUsedPricelistItemCategories(pricelistRepo *repository.PricelistRepository, activeServerPricelistIDs []uint) {
|
||||||
|
if s.localDB == nil || pricelistRepo == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
activeSet := make(map[uint]struct{}, len(activeServerPricelistIDs))
|
||||||
|
for _, id := range activeServerPricelistIDs {
|
||||||
|
activeSet[id] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type row struct {
|
||||||
|
ID uint `gorm:"column:id"`
|
||||||
|
}
|
||||||
|
var usedRows []row
|
||||||
|
if err := s.localDB.DB().Raw(`
|
||||||
|
SELECT DISTINCT pricelist_id AS id
|
||||||
|
FROM local_configurations
|
||||||
|
WHERE is_active = 1 AND pricelist_id IS NOT NULL
|
||||||
|
UNION
|
||||||
|
SELECT DISTINCT warehouse_pricelist_id AS id
|
||||||
|
FROM local_configurations
|
||||||
|
WHERE is_active = 1 AND warehouse_pricelist_id IS NOT NULL
|
||||||
|
UNION
|
||||||
|
SELECT DISTINCT competitor_pricelist_id AS id
|
||||||
|
FROM local_configurations
|
||||||
|
WHERE is_active = 1 AND competitor_pricelist_id IS NOT NULL
|
||||||
|
`).Scan(&usedRows).Error; err != nil {
|
||||||
|
slog.Warn("pricelist category backfill: failed to list used pricelists", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range usedRows {
|
||||||
|
serverID := r.ID
|
||||||
|
if serverID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := activeSet[serverID]; !ok {
|
||||||
|
// Not present on server (or not active) - cannot backfill from remote.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
localPL, err := s.localDB.GetLocalPricelistByServerID(serverID)
|
||||||
|
if err != nil || localPL == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.localDB.CountLocalPricelistItems(localPL.ID) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
missing, err := s.localDB.CountLocalPricelistItemsWithEmptyCategory(localPL.ID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("pricelist category backfill: failed to check local items", "server_id", serverID, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if missing == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
serverItems, _, err := pricelistRepo.GetItems(serverID, 0, 10000, "")
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("pricelist category backfill: failed to load server items", "server_id", serverID, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
localItems := make([]localdb.LocalPricelistItem, len(serverItems))
|
||||||
|
for i := range serverItems {
|
||||||
|
localItems[i] = *localdb.PricelistItemToLocal(&serverItems[i], localPL.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.localDB.ReplaceLocalPricelistItems(localPL.ID, localItems); err != nil {
|
||||||
|
slog.Warn("pricelist category backfill: failed to replace local items", "server_id", serverID, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
slog.Info("pricelist category backfill: refreshed local items", "server_id", serverID, "items", len(localItems))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// RecordSyncHeartbeat updates shared sync heartbeat for current DB user.
|
// RecordSyncHeartbeat updates shared sync heartbeat for current DB user.
|
||||||
// Only users with write rights are expected to be able to update this table.
|
// Only users with write rights are expected to be able to update this table.
|
||||||
func (s *Service) RecordSyncHeartbeat() {
|
func (s *Service) RecordSyncHeartbeat() {
|
||||||
@@ -595,15 +675,7 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
|
|||||||
// Convert and save locally
|
// Convert and save locally
|
||||||
localItems := make([]localdb.LocalPricelistItem, len(serverItems))
|
localItems := make([]localdb.LocalPricelistItem, len(serverItems))
|
||||||
for i, item := range serverItems {
|
for i, item := range serverItems {
|
||||||
partnumbers := make(localdb.LocalStringList, 0, len(item.Partnumbers))
|
localItems[i] = *localdb.PricelistItemToLocal(&item, localPricelistID)
|
||||||
partnumbers = append(partnumbers, item.Partnumbers...)
|
|
||||||
localItems[i] = localdb.LocalPricelistItem{
|
|
||||||
PricelistID: localPricelistID,
|
|
||||||
LotName: item.LotName,
|
|
||||||
Price: item.Price,
|
|
||||||
AvailableQty: item.AvailableQty,
|
|
||||||
Partnumbers: partnumbers,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.localDB.SaveLocalPricelistItems(localItems); err != nil {
|
if err := s.localDB.SaveLocalPricelistItems(localItems); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package sync_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
|
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSyncPricelists_BackfillsLotCategoryForUsedPricelistItems(t *testing.T) {
|
||||||
|
local := newLocalDBForSyncTest(t)
|
||||||
|
serverDB := newServerDBForSyncTest(t)
|
||||||
|
|
||||||
|
if err := serverDB.AutoMigrate(
|
||||||
|
&models.Pricelist{},
|
||||||
|
&models.PricelistItem{},
|
||||||
|
&models.Lot{},
|
||||||
|
&models.LotPartnumber{},
|
||||||
|
&models.StockLog{},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("migrate server tables: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
serverPL := models.Pricelist{
|
||||||
|
Source: "estimate",
|
||||||
|
Version: "2026-02-11-001",
|
||||||
|
Notification: "server",
|
||||||
|
CreatedBy: "tester",
|
||||||
|
IsActive: true,
|
||||||
|
CreatedAt: time.Now().Add(-1 * time.Hour),
|
||||||
|
}
|
||||||
|
if err := serverDB.Create(&serverPL).Error; err != nil {
|
||||||
|
t.Fatalf("create server pricelist: %v", err)
|
||||||
|
}
|
||||||
|
if err := serverDB.Create(&models.PricelistItem{
|
||||||
|
PricelistID: serverPL.ID,
|
||||||
|
LotName: "CPU_A",
|
||||||
|
LotCategory: "CPU",
|
||||||
|
Price: 10,
|
||||||
|
PriceMethod: "",
|
||||||
|
MetaPrices: "",
|
||||||
|
ManualPrice: nil,
|
||||||
|
AvailableQty: nil,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("create server pricelist item: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||||
|
ServerID: serverPL.ID,
|
||||||
|
Source: serverPL.Source,
|
||||||
|
Version: serverPL.Version,
|
||||||
|
Name: serverPL.Notification,
|
||||||
|
CreatedAt: serverPL.CreatedAt,
|
||||||
|
SyncedAt: time.Now(),
|
||||||
|
IsUsed: false,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed local pricelist: %v", err)
|
||||||
|
}
|
||||||
|
localPL, err := local.GetLocalPricelistByServerID(serverPL.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get local pricelist: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
||||||
|
{
|
||||||
|
PricelistID: localPL.ID,
|
||||||
|
LotName: "CPU_A",
|
||||||
|
LotCategory: "",
|
||||||
|
Price: 10,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed local pricelist items: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := local.SaveConfiguration(&localdb.LocalConfiguration{
|
||||||
|
UUID: "cfg-1",
|
||||||
|
OriginalUsername: "tester",
|
||||||
|
Name: "cfg",
|
||||||
|
Items: localdb.LocalConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 10}},
|
||||||
|
IsActive: true,
|
||||||
|
PricelistID: &serverPL.ID,
|
||||||
|
SyncStatus: "synced",
|
||||||
|
CreatedAt: time.Now().Add(-30 * time.Minute),
|
||||||
|
UpdatedAt: time.Now().Add(-30 * time.Minute),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed local configuration with pricelist ref: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := syncsvc.NewServiceWithDB(serverDB, local)
|
||||||
|
if _, err := svc.SyncPricelists(); err != nil {
|
||||||
|
t.Fatalf("sync pricelists: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := local.GetLocalPricelistItems(localPL.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load local items: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("expected 1 local item, got %d", len(items))
|
||||||
|
}
|
||||||
|
if items[0].LotCategory != "CPU" {
|
||||||
|
t.Fatalf("expected lot_category backfilled to CPU, got %q", items[0].LotCategory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -348,6 +348,9 @@ CREATE TABLE qt_configurations (
|
|||||||
notes TEXT NULL,
|
notes TEXT NULL,
|
||||||
is_template INTEGER NOT NULL DEFAULT 0,
|
is_template INTEGER NOT NULL DEFAULT 0,
|
||||||
server_count INTEGER NOT NULL DEFAULT 1,
|
server_count INTEGER NOT NULL DEFAULT 1,
|
||||||
|
server_model TEXT NULL,
|
||||||
|
support_code TEXT NULL,
|
||||||
|
article TEXT NULL,
|
||||||
pricelist_id INTEGER NULL,
|
pricelist_id INTEGER NULL,
|
||||||
warehouse_pricelist_id INTEGER NULL,
|
warehouse_pricelist_id INTEGER NULL,
|
||||||
competitor_pricelist_id INTEGER NULL,
|
competitor_pricelist_id INTEGER NULL,
|
||||||
|
|||||||
38
memory.md
Normal file
38
memory.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Changes summary (2026-02-11)
|
||||||
|
|
||||||
|
Implemented strict `lot_category` flow using `pricelist_items.lot_category` only (no parsing from `lot_name`), plus local caching and backfill:
|
||||||
|
|
||||||
|
1. Local DB schema + migrations
|
||||||
|
- Added `lot_category` column to `local_pricelist_items` via `LocalPricelistItem` model.
|
||||||
|
- Added local migration `2026_02_11_local_pricelist_item_category` to add the column if missing and create indexes:
|
||||||
|
- `idx_local_pricelist_items_pricelist_lot (pricelist_id, lot_name)`
|
||||||
|
- `idx_local_pricelist_items_lot_category (lot_category)`
|
||||||
|
|
||||||
|
2. Server model/repository
|
||||||
|
- Added `LotCategory` field to `models.PricelistItem`.
|
||||||
|
- `PricelistRepository.GetItems` now sets `Category` from `LotCategory` (no parsing from `lot_name`).
|
||||||
|
|
||||||
|
3. Sync + local DB helpers
|
||||||
|
- `SyncPricelistItems` now saves `lot_category` into local cache via `PricelistItemToLocal`.
|
||||||
|
- Added `LocalDB.CountLocalPricelistItemsWithEmptyCategory` and `LocalDB.ReplaceLocalPricelistItems`.
|
||||||
|
- Added `LocalDB.GetLocalLotCategoriesByServerPricelistID` for strict category lookup.
|
||||||
|
- Added `SyncPricelists` backfill step: for used active pricelists with empty categories, force refresh items from server.
|
||||||
|
|
||||||
|
4. API handler
|
||||||
|
- `GET /api/pricelists/:id/items` returns `category` from `local_pricelist_items.lot_category` (no parsing from `lot_name`).
|
||||||
|
|
||||||
|
5. Article category foundation
|
||||||
|
- New package `internal/article`:
|
||||||
|
- `ResolveLotCategoriesStrict` pulls categories from local pricelist items and errors on missing category.
|
||||||
|
- `GroupForLotCategory` maps only allowed codes (CPU/MEM/GPU/M2/SSD/HDD/EDSFF/HHHL/NIC/HCA/DPU/PSU/PS) to article groups; excludes `SFP`.
|
||||||
|
- Error type `MissingCategoryForLotError` with base `ErrMissingCategoryForLot`.
|
||||||
|
|
||||||
|
6. Tests
|
||||||
|
- Added unit tests for converters and article category resolver.
|
||||||
|
- Added handler test to ensure `/api/pricelists/:id/items` returns `lot_category`.
|
||||||
|
- Added sync test for category backfill on used pricelist items.
|
||||||
|
- `go test ./...` passed.
|
||||||
|
|
||||||
|
Additional fixes (2026-02-11):
|
||||||
|
- Fixed article parsing bug: CPU/GPU parsers were swapped in `internal/article/generator.go`. CPU now uses last token from CPU lot; GPU uses model+memory from `GPU_vendor_model_mem_iface`.
|
||||||
|
- Adjusted configurator base tab layout to align labels on the same row (separate label row + input row grid).
|
||||||
2
migrations/022_add_article_to_configurations.sql
Normal file
2
migrations/022_add_article_to_configurations.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE qt_configurations
|
||||||
|
ADD COLUMN IF NOT EXISTS article VARCHAR(80) NULL AFTER server_count;
|
||||||
2
migrations/023_add_server_model_to_configurations.sql
Normal file
2
migrations/023_add_server_model_to_configurations.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE qt_configurations
|
||||||
|
ADD COLUMN IF NOT EXISTS server_model VARCHAR(100) NULL AFTER server_count;
|
||||||
2
migrations/024_add_support_code_to_configurations.sql
Normal file
2
migrations/024_add_support_code_to_configurations.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE qt_configurations
|
||||||
|
ADD COLUMN IF NOT EXISTS support_code VARCHAR(20) NULL AFTER server_model;
|
||||||
315
pricelists_window.md
Normal file
315
pricelists_window.md
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
# Промпт для ИИ: Перенос паттерна Прайслист
|
||||||
|
|
||||||
|
Используй этот документ как промпт для ИИ при переносе реализации прайслиста в другой проект.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Задача
|
||||||
|
|
||||||
|
Я имею рабочую реализацию окна "Прайслист" в проекте QuoteForge. Нужно перенести эту реализацию в проект [ДОП_ПРОЕКТ_НАЗВАНИЕ], сохраняя структуру, логику и UI/UX.
|
||||||
|
|
||||||
|
## Что перенести
|
||||||
|
|
||||||
|
### Frontend - Лист прайслистов (`/pricelists`)
|
||||||
|
|
||||||
|
**Файл источник:** QuoteForge/web/templates/pricelists.html
|
||||||
|
|
||||||
|
**Компоненты:**
|
||||||
|
1. **Таблица** - список прайслистов с колонками:
|
||||||
|
- Версия (монофонт)
|
||||||
|
- Тип (estimate/warehouse/competitor)
|
||||||
|
- Дата создания
|
||||||
|
- Автор (обычно "sync")
|
||||||
|
- Позиций (количество товаров)
|
||||||
|
- Исп. (использований)
|
||||||
|
- Статус (зеленый "Активен" / серый "Неактивен")
|
||||||
|
- Действия (Просмотр, Удалить если не используется)
|
||||||
|
|
||||||
|
2. **Пагинация** - навигация по страницам с активной страницей выделена
|
||||||
|
|
||||||
|
3. **Модальное окно** - "Создать прайслист" (если есть прав на запись)
|
||||||
|
|
||||||
|
**Что копировать:**
|
||||||
|
- HTML структуру таблицы из lines 10-30
|
||||||
|
- JavaScript функции:
|
||||||
|
- `loadPricelists(page)` - загрузка списка
|
||||||
|
- `renderPricelists(items)` - рендер таблицы
|
||||||
|
- `renderPagination(total, page, perPage)` - пагинация
|
||||||
|
- `checkPricelistWritePermission()` - проверка прав
|
||||||
|
- Модальные функции: `openCreateModal()`, `closeCreateModal()`, `createPricelist()`
|
||||||
|
- CSS классы Tailwind (скопируются как есть)
|
||||||
|
|
||||||
|
**Где использовать в дочернем проекте:**
|
||||||
|
- URL: `/pricelists` (или адаптировать под ваши маршруты)
|
||||||
|
- API: `GET /api/pricelists?page=1&per_page=20`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Frontend - Детали прайслиста (`/pricelists/:id`)
|
||||||
|
|
||||||
|
**Файл источник:** QuoteForge/web/templates/pricelist_detail.html
|
||||||
|
|
||||||
|
**Компоненты:**
|
||||||
|
1. **Хлебная крошка** - кнопка назад на список
|
||||||
|
|
||||||
|
2. **Инфо-панель** - сводка по прайслисту:
|
||||||
|
- Версия (монофонт)
|
||||||
|
- Дата создания
|
||||||
|
- Автор
|
||||||
|
- Позиций (количество)
|
||||||
|
- Использований (в скольких конфигах)
|
||||||
|
- Статус (зеленый/серый)
|
||||||
|
- Истекает (дата или "-")
|
||||||
|
|
||||||
|
3. **Таблица товаров** - с поиском и пагинацией:
|
||||||
|
- Артикул (монофонт, lot_name)
|
||||||
|
- Категория (извлекается первая часть до "_")
|
||||||
|
- Описание (обрезается до 60 символов с "...")
|
||||||
|
- [УСЛОВНО] Доступно (qty) - только для warehouse источника
|
||||||
|
- [УСЛОВНО] Partnumbers - только для warehouse источника
|
||||||
|
- Цена, $ (с 2 знаками после запятой)
|
||||||
|
- Настройки (аббревиатуры: РУЧН, Сред, Взвеш.мед, периоды (1н, 1м, 3м, 1г), коэффициент, МЕТА)
|
||||||
|
|
||||||
|
4. **Поиск** - дебаунс 300мс, поиск по lot_name
|
||||||
|
|
||||||
|
5. **Динамические колонки** - qty и partnumbers скрываются/показываются в зависимости от source (warehouse или нет)
|
||||||
|
|
||||||
|
**Что копировать:**
|
||||||
|
- HTML структуру из lines 4-78
|
||||||
|
- JavaScript функции:
|
||||||
|
- `loadPricelistInfo()` - загрузка деталей прайслиста
|
||||||
|
- `loadItems(page)` - загрузка товаров
|
||||||
|
- `renderItems(items)` - рендер таблицы товаров
|
||||||
|
- `renderItemsPagination(total, page, perPage)` - пагинация товаров
|
||||||
|
- `isWarehouseSource()` - проверка источника
|
||||||
|
- `toggleWarehouseColumns()` - показать/скрыть conditional колонки
|
||||||
|
- `formatQty(qty)` - форматирование количества
|
||||||
|
- `formatPriceSettings(item)` - форматирование строки настроек
|
||||||
|
- `escapeHtml(text)` - экранирование HTML
|
||||||
|
- Debounce для поиска (lines 300-306)
|
||||||
|
- CSS классы Tailwind
|
||||||
|
- Логику conditional колонок (lines 152-164)
|
||||||
|
|
||||||
|
**Где использовать в дочернем проекте:**
|
||||||
|
- URL: `/pricelists/:id`
|
||||||
|
- API:
|
||||||
|
- `GET /api/pricelists/:id`
|
||||||
|
- `GET /api/pricelists/:id/items?page=1&per_page=50&search=...`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Backend - Handler
|
||||||
|
|
||||||
|
**Файл источник:** QuoteForge/internal/handlers/pricelist.go
|
||||||
|
|
||||||
|
**Методы для реализации:**
|
||||||
|
|
||||||
|
1. **List** (lines 23-89)
|
||||||
|
- Параметры: `page`, `per_page`, `source` (фильтр), `active_only`
|
||||||
|
- Логика:
|
||||||
|
- Получить все прайслисты
|
||||||
|
- Отфильтровать по source (case-insensitive)
|
||||||
|
- Отсортировать по CreatedAt DESC (свежее сверху)
|
||||||
|
- Пагинировать
|
||||||
|
- Для каждого: посчитать товары (CountLocalPricelistItems), использования (IsUsed)
|
||||||
|
- Вернуть JSON с полями: id, source, version, created_by, item_count, usage_count, is_active, created_at, synced_from
|
||||||
|
|
||||||
|
2. **Get** (lines 92-116)
|
||||||
|
- Параметр: `id` (uint из URL)
|
||||||
|
- Логика:
|
||||||
|
- Получить прайслист по ID
|
||||||
|
- Вернуть его детали (id, source, version, item_count, is_active, created_at)
|
||||||
|
- 404 если не найден
|
||||||
|
|
||||||
|
3. **GetItems** (lines 119-181)
|
||||||
|
- Параметры: `id` (URL), `page`, `per_page`, `search` (query)
|
||||||
|
- Логика:
|
||||||
|
- Получить прайслист по ID
|
||||||
|
- Получить товары этого прайслиста
|
||||||
|
- Фильтровать по lot_name LIKE search (если передан)
|
||||||
|
- Посчитать total
|
||||||
|
- Пагинировать
|
||||||
|
- Для каждого товара: извлечь категорию из lot_name (первая часть до "_")
|
||||||
|
- Вернуть JSON: source, items (id, lot_name, price, category, available_qty, partnumbers), total, page, per_page
|
||||||
|
|
||||||
|
4. **GetLotNames** (lines 183-211)
|
||||||
|
- Параметр: `id` (URL)
|
||||||
|
- Логика:
|
||||||
|
- Получить все lot_names из этого прайслиста
|
||||||
|
- Отсортировать alphabetically
|
||||||
|
- Вернуть JSON: lot_names (array of strings), total
|
||||||
|
|
||||||
|
5. **GetLatest** (lines 214-233)
|
||||||
|
- Параметр: `source` (query, default "estimate")
|
||||||
|
- Логика:
|
||||||
|
- Нормализовать source (case-insensitive)
|
||||||
|
- Получить самый свежий прайслист по этому source
|
||||||
|
- Вернуть его детали
|
||||||
|
- 404 если не найден
|
||||||
|
|
||||||
|
**Регистрация маршрутов:**
|
||||||
|
```go
|
||||||
|
pricelists := api.Group("/pricelists")
|
||||||
|
{
|
||||||
|
pricelists.GET("", handler.List)
|
||||||
|
pricelists.GET("/latest", handler.GetLatest)
|
||||||
|
pricelists.GET("/:id", handler.Get)
|
||||||
|
pricelists.GET("/:id/items", handler.GetItems)
|
||||||
|
pricelists.GET("/:id/lots", handler.GetLotNames)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Адаптация для другого проекта
|
||||||
|
|
||||||
|
### Что нужно изменить
|
||||||
|
|
||||||
|
1. **Источник данных**
|
||||||
|
- QuoteForge использует local DB (LocalPricelist, LocalPricelistItem)
|
||||||
|
- В вашем проекте: замените на ваши структуры/таблицы
|
||||||
|
- Сущность "прайслист" может называться по-другому
|
||||||
|
|
||||||
|
2. **API маршруты**
|
||||||
|
- `/api/pricelists` → ваш путь
|
||||||
|
- `:id` - может быть UUID вместо int, адаптировать parsing
|
||||||
|
|
||||||
|
3. **Имена полей**
|
||||||
|
- Если у вас нет поля `version` - используйте ID или дату
|
||||||
|
- Если нет `source` - опустить фильтр
|
||||||
|
- Если нет `IsUsed` - считать как всегда 0
|
||||||
|
|
||||||
|
4. **Структуры данных**
|
||||||
|
- Pricelist должна иметь: id, name/version, created_at, source, item_count
|
||||||
|
- PricelistItem должна иметь: id, lot_name, price, available_qty, partnumbers
|
||||||
|
|
||||||
|
5. **Условные колонки**
|
||||||
|
- Логика: если source == "warehouse", показать qty и partnumbers
|
||||||
|
- Адаптировать под ваши источники/типы
|
||||||
|
|
||||||
|
### Что копировать как есть
|
||||||
|
|
||||||
|
- **HTML структура** - таблицы, модали, классы Tailwind
|
||||||
|
- **JavaScript логика** - все функции загрузки, рендера, пагинации
|
||||||
|
- **CSS классы** - Tailwind работает везде одинаково
|
||||||
|
- **Форматирование функций** - formatPrice, formatQty, formatDate
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Пошаговая инструкция для ИИ
|
||||||
|
|
||||||
|
1. **Прочитай оба файла:**
|
||||||
|
- QuoteForge/web/templates/pricelists.html (список)
|
||||||
|
- QuoteForge/web/templates/pricelist_detail.html (детали)
|
||||||
|
- QuoteForge/internal/handlers/pricelist.go (backend)
|
||||||
|
|
||||||
|
2. **Определи структуры данных в дочернем проекте:**
|
||||||
|
- Какая таблица хранит "прайслисты"?
|
||||||
|
- Какие у неё поля?
|
||||||
|
- Как связаны товары?
|
||||||
|
|
||||||
|
3. **Адаптируй Backend:**
|
||||||
|
- Скопируй методы Handler
|
||||||
|
- Замени DB вызовы на вызовы вашего хранилища
|
||||||
|
- Замени имена полей в JSON ответах если нужно
|
||||||
|
- Убедись, что API возвращает нужный формат
|
||||||
|
|
||||||
|
4. **Адаптируй Frontend - Список:**
|
||||||
|
- Скопируй HTML таблицу
|
||||||
|
- Скопируй функции load/render/pagination
|
||||||
|
- Замени маршруты `/pricelists` → ваши
|
||||||
|
- Замени API endpoint → ваш
|
||||||
|
- Протестируй список загружается
|
||||||
|
|
||||||
|
5. **Адаптируй Frontend - Детали:**
|
||||||
|
- Скопируй HTML для деталей
|
||||||
|
- Скопируй функции loadInfo/loadItems/render
|
||||||
|
- Замени маршруты и endpoints
|
||||||
|
- Особое внимание на conditional колонки (toggleWarehouseColumns)
|
||||||
|
- Протестируй поиск работает
|
||||||
|
|
||||||
|
6. **Протестируй:**
|
||||||
|
- Список загружается
|
||||||
|
- Пагинация работает
|
||||||
|
- Детали открываются
|
||||||
|
- Поиск работает
|
||||||
|
- Conditional колонки показываются/скрываются правильно
|
||||||
|
- Форматирование цен и дат работает
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Пример адаптации
|
||||||
|
|
||||||
|
### Backend (было):
|
||||||
|
```go
|
||||||
|
func (h *PricelistHandler) List(c *gin.Context) {
|
||||||
|
localPLs, err := h.localDB.GetLocalPricelists()
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend (стало):
|
||||||
|
```go
|
||||||
|
func (h *CatalogHandler) List(c *gin.Context) {
|
||||||
|
catalogs, err := h.service.GetAllCatalogs(page, perPage)
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend (было):
|
||||||
|
```javascript
|
||||||
|
const resp = await fetch(`/api/pricelists?page=${page}&per_page=20`);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend (стало):
|
||||||
|
```javascript
|
||||||
|
const resp = await fetch(`/api/catalogs?page=${page}&per_page=20`);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Качество результата
|
||||||
|
|
||||||
|
Когда закончишь:
|
||||||
|
- ✅ Список и детали выглядят идентично QuoteForge
|
||||||
|
- ✅ Все функции работают (load, render, pagination, search, conditional columns)
|
||||||
|
- ✅ Обработка ошибок (404, empty list, network errors)
|
||||||
|
- ✅ Таблицы с Tailwind классами оформлены одинаково
|
||||||
|
- ✅ Форматирование чисел/дат совпадает
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Вопросы для ИИ
|
||||||
|
|
||||||
|
Перед тем как давать этот промпт, ответь на эти вопросы:
|
||||||
|
|
||||||
|
1. **Какие у тебя структуры данных для "прайслиста"?**
|
||||||
|
- Пример: какие поля, как называется таблица
|
||||||
|
|
||||||
|
2. **Какие API endpoints уже есть?**
|
||||||
|
- Или нужно создать с нуля?
|
||||||
|
|
||||||
|
3. **Есть ли уже разница в источниках (estimate/warehouse)?**
|
||||||
|
- Или все одного типа?
|
||||||
|
|
||||||
|
4. **Нужна ли возможность создавать прайслисты?**
|
||||||
|
- Или только просмотр?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Чеклист для проверки
|
||||||
|
|
||||||
|
После переноса проверь:
|
||||||
|
|
||||||
|
- [ ] Backend: List возвращает правильный JSON
|
||||||
|
- [ ] Backend: Get возвращает детали
|
||||||
|
- [ ] Backend: GetItems возвращает товары с поиском
|
||||||
|
- [ ] Frontend: Список загружается на `/pricelists`
|
||||||
|
- [ ] Frontend: Клик на прайслист открывает `/pricelists/:id`
|
||||||
|
- [ ] Frontend: Таблица на детальной странице рендеритсяся
|
||||||
|
- [ ] Frontend: Поиск работает с дебаунсом
|
||||||
|
- [ ] Frontend: Пагинация работает
|
||||||
|
- [ ] Frontend: Conditional колонки показываются/скрываются
|
||||||
|
- [ ] Frontend: Форматирование цен работает (2 знака)
|
||||||
|
- [ ] Frontend: Форматирование дат работает (ru-RU)
|
||||||
|
- [ ] UI: Выглядит идентично QuoteForge
|
||||||
95
releases/memory/v1.2.3.md
Normal file
95
releases/memory/v1.2.3.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# Release v1.2.3 (2026-02-10)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Unified synchronization functionality with event-driven UI updates. Resolved user confusion about duplicate sync buttons by implementing a single sync source with automatic page refreshes.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### Main Feature: Sync Event System
|
||||||
|
|
||||||
|
- **Added `sync-completed` event** in base.html's `syncAction()` function
|
||||||
|
- Dispatched after successful `/api/sync/all` or `/api/sync/push`
|
||||||
|
- Includes endpoint and response data in event detail
|
||||||
|
- Enables pages to react automatically to sync completion
|
||||||
|
|
||||||
|
### Configs Page (`configs.html`)
|
||||||
|
|
||||||
|
- **Removed "Импорт с сервера" button** - duplicate functionality no longer needed
|
||||||
|
- **Updated layout** - changed from 2-column grid to single button layout
|
||||||
|
- **Removed `importConfigsFromServer()` function** - functionality now handled by navbar sync
|
||||||
|
- **Added sync-completed event listener**:
|
||||||
|
- Automatically reloads configurations list after sync
|
||||||
|
- Resets pagination to first page
|
||||||
|
- New configurations appear immediately without manual refresh
|
||||||
|
|
||||||
|
### Projects Page (`projects.html`)
|
||||||
|
|
||||||
|
- **Wrapped initialization in DOMContentLoaded**:
|
||||||
|
- Moved `loadProjects()` and all event listeners inside handler
|
||||||
|
- Ensures DOM is fully loaded before accessing elements
|
||||||
|
- **Added sync-completed event listener**:
|
||||||
|
- Automatically reloads projects list after sync
|
||||||
|
- New projects appear immediately without manual refresh
|
||||||
|
|
||||||
|
### Pricelists Page (`pricelists.html`)
|
||||||
|
|
||||||
|
- **Added sync-completed event listener** to existing DOMContentLoaded:
|
||||||
|
- Automatically reloads pricelists when sync completes
|
||||||
|
- Maintains existing permissions and modal functionality
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
### User Experience
|
||||||
|
- ✅ Single "Синхронизация" button in navbar - no confusion about sync sources
|
||||||
|
- ✅ Automatic list updates after sync - no need for manual F5 refresh
|
||||||
|
- ✅ Consistent behavior across all pages (configs, projects, pricelists)
|
||||||
|
- ✅ Better feedback: toast notification + automatic UI refresh
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
- ✅ Event-driven loose coupling between navbar and pages
|
||||||
|
- ✅ Easy to extend to other pages (just add event listener)
|
||||||
|
- ✅ No backend changes needed
|
||||||
|
- ✅ Production-ready
|
||||||
|
|
||||||
|
## Breaking Changes
|
||||||
|
|
||||||
|
- **`/api/configs/import` endpoint** still works but UI button removed
|
||||||
|
- Users should use navbar "Синхронизация" button instead
|
||||||
|
- Backend API remains unchanged for backward compatibility
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
1. `web/templates/base.html` - Added sync-completed event dispatch
|
||||||
|
2. `web/templates/configs.html` - Event listener + removed duplicate UI
|
||||||
|
3. `web/templates/projects.html` - DOMContentLoaded wrapper + event listener
|
||||||
|
4. `web/templates/pricelists.html` - Event listener for auto-refresh
|
||||||
|
|
||||||
|
**Stats:** 4 files changed, 59 insertions(+), 65 deletions(-)
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
- `99fd80b` - feat: unify sync functionality with event-driven UI updates
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [x] Configs page: New configurations appear after navbar sync
|
||||||
|
- [x] Projects page: New projects appear after navbar sync
|
||||||
|
- [x] Pricelists page: Pricelists refresh after navbar sync
|
||||||
|
- [x] Both `/api/sync/all` and `/api/sync/push` trigger updates
|
||||||
|
- [x] Toast notifications still show correctly
|
||||||
|
- [x] Sync status indicator updates
|
||||||
|
- [x] Error handling (423, network errors) still works
|
||||||
|
- [x] Mode switching (Active/Archive) works correctly
|
||||||
|
- [x] Backward compatibility maintained
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
None - implementation is production-ready
|
||||||
|
|
||||||
|
## Migration Notes
|
||||||
|
|
||||||
|
No migration needed. Changes are frontend-only and backward compatible:
|
||||||
|
- Old `/api/configs/import` endpoint still functional
|
||||||
|
- No database schema changes
|
||||||
|
- No configuration changes needed
|
||||||
@@ -249,10 +249,23 @@ function renderConfigs(configs) {
|
|||||||
html += '<td class="px-4 py-3 text-sm text-gray-700">' + escapeHtml(projectName) + '</td>';
|
html += '<td class="px-4 py-3 text-sm text-gray-700">' + escapeHtml(projectName) + '</td>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const article = c.article ? escapeHtml(c.article) : '';
|
||||||
|
const serverModel = c.server_model ? escapeHtml(c.server_model) : '';
|
||||||
|
const subtitle = article || serverModel;
|
||||||
if (configStatusMode === 'archived') {
|
if (configStatusMode === 'archived') {
|
||||||
html += '<td class="px-4 py-3 text-sm font-medium text-gray-700">' + escapeHtml(c.name) + '</td>';
|
html += '<td class="px-4 py-3 text-sm font-medium text-gray-700">';
|
||||||
|
html += '<div>' + escapeHtml(c.name) + '</div>';
|
||||||
|
if (subtitle) {
|
||||||
|
html += '<div class="text-xs text-gray-500">' + subtitle + '</div>';
|
||||||
|
}
|
||||||
|
html += '</td>';
|
||||||
} else {
|
} else {
|
||||||
html += '<td class="px-4 py-3 text-sm font-medium"><a href="/configurator?uuid=' + c.uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(c.name) + '</a></td>';
|
html += '<td class="px-4 py-3 text-sm font-medium">';
|
||||||
|
html += '<a href="/configurator?uuid=' + c.uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(c.name) + '</a>';
|
||||||
|
if (subtitle) {
|
||||||
|
html += '<div class="text-xs text-gray-500">' + subtitle + '</div>';
|
||||||
|
}
|
||||||
|
html += '</td>';
|
||||||
}
|
}
|
||||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(author) + '</td>';
|
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(author) + '</td>';
|
||||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + pricePerUnit + '</td>';
|
html += '<td class="px-4 py-3 text-sm text-gray-500">' + pricePerUnit + '</td>';
|
||||||
|
|||||||
@@ -98,6 +98,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div id="cart-summary-content" class="p-4">
|
<div id="cart-summary-content" class="p-4">
|
||||||
|
<div id="article-display" class="text-sm text-gray-700 mb-3 font-mono"></div>
|
||||||
<div id="cart-items" class="space-y-2 mb-4"></div>
|
<div id="cart-items" class="space-y-2 mb-4"></div>
|
||||||
<div class="border-t pt-3 flex justify-between items-center">
|
<div class="border-t pt-3 flex justify-between items-center">
|
||||||
<div class="text-lg font-bold">
|
<div class="text-lg font-bold">
|
||||||
@@ -334,6 +335,10 @@ let cart = [];
|
|||||||
let categoryOrderMap = {}; // Category code -> display_order mapping
|
let categoryOrderMap = {}; // Category code -> display_order mapping
|
||||||
let autoSaveTimeout = null; // Timeout for debounced autosave
|
let autoSaveTimeout = null; // Timeout for debounced autosave
|
||||||
let serverCount = 1; // Server count for the configuration
|
let serverCount = 1; // Server count for the configuration
|
||||||
|
let serverModelForQuote = '';
|
||||||
|
let supportCode = '';
|
||||||
|
let currentArticle = '';
|
||||||
|
let articlePreviewTimeout = null;
|
||||||
let selectedPricelistIds = {
|
let selectedPricelistIds = {
|
||||||
estimate: null,
|
estimate: null,
|
||||||
warehouse: null,
|
warehouse: null,
|
||||||
@@ -634,6 +639,9 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||||||
category: item.category || getCategoryFromLotName(item.lot_name)
|
category: item.category || getCategoryFromLotName(item.lot_name)
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
serverModelForQuote = config.server_model || '';
|
||||||
|
supportCode = config.support_code || '';
|
||||||
|
currentArticle = config.article || '';
|
||||||
|
|
||||||
// Restore custom price if saved
|
// Restore custom price if saved
|
||||||
if (config.custom_price) {
|
if (config.custom_price) {
|
||||||
@@ -948,7 +956,32 @@ function renderTab() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderSingleSelectTab(categories) {
|
function renderSingleSelectTab(categories) {
|
||||||
let html = `
|
let html = '';
|
||||||
|
if (currentTab === 'base') {
|
||||||
|
html += `
|
||||||
|
<div class="mb-1 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
|
||||||
|
<label for="server-model-input" class="block text-sm font-medium text-gray-700">Модель сервера для КП:</label>
|
||||||
|
<label for="support-code-select" class="block text-sm font-medium text-gray-700">Уровень техподдержки:</label>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
|
||||||
|
<input type="text"
|
||||||
|
id="server-model-input"
|
||||||
|
value="${escapeHtml(serverModelForQuote)}"
|
||||||
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||||||
|
oninput="updateServerModelForQuote(this.value)">
|
||||||
|
<select id="support-code-select"
|
||||||
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||||||
|
onchange="updateSupportCode(this.value)">
|
||||||
|
<option value="">—</option>
|
||||||
|
<option value="1yW" ${supportCode === '1yW' ? 'selected' : ''}>1yW</option>
|
||||||
|
<option value="1yB" ${supportCode === '1yB' ? 'selected' : ''}>1yB</option>
|
||||||
|
<option value="1yS" ${supportCode === '1yS' ? 'selected' : ''}>1yS</option>
|
||||||
|
<option value="1yP" ${supportCode === '1yP' ? 'selected' : ''}>1yP</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
html += `
|
||||||
<table class="w-full">
|
<table class="w-full">
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -1636,6 +1669,8 @@ function updateCartUI() {
|
|||||||
calculateCustomPrice();
|
calculateCustomPrice();
|
||||||
renderSalePriceTable();
|
renderSalePriceTable();
|
||||||
|
|
||||||
|
scheduleArticlePreview();
|
||||||
|
|
||||||
if (cart.length === 0) {
|
if (cart.length === 0) {
|
||||||
document.getElementById('cart-items').innerHTML =
|
document.getElementById('cart-items').innerHTML =
|
||||||
'<div class="text-gray-500 text-center py-2">Конфигурация пуста</div>';
|
'<div class="text-gray-500 text-center py-2">Конфигурация пуста</div>';
|
||||||
@@ -1711,6 +1746,69 @@ function escapeHtml(text) {
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateServerModelForQuote(value) {
|
||||||
|
serverModelForQuote = value || '';
|
||||||
|
scheduleArticlePreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSupportCode(value) {
|
||||||
|
supportCode = value || '';
|
||||||
|
scheduleArticlePreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleArticlePreview() {
|
||||||
|
if (articlePreviewTimeout) {
|
||||||
|
clearTimeout(articlePreviewTimeout);
|
||||||
|
}
|
||||||
|
articlePreviewTimeout = setTimeout(() => {
|
||||||
|
previewArticle();
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function previewArticle() {
|
||||||
|
const el = document.getElementById('article-display');
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const model = serverModelForQuote.trim();
|
||||||
|
if (!model || !selectedPricelistIds.estimate || cart.length === 0) {
|
||||||
|
currentArticle = '';
|
||||||
|
el.textContent = 'Артикул: —';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/configs/preview-article', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({
|
||||||
|
server_model: serverModelForQuote,
|
||||||
|
support_code: supportCode,
|
||||||
|
pricelist_id: selectedPricelistIds.estimate,
|
||||||
|
items: cart.map(item => ({
|
||||||
|
lot_name: item.lot_name,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unit_price: item.unit_price || 0
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
currentArticle = '';
|
||||||
|
el.textContent = 'Артикул: —';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
currentArticle = data.article || '';
|
||||||
|
el.textContent = currentArticle ? ('Артикул: ' + currentArticle) : 'Артикул: —';
|
||||||
|
} catch(e) {
|
||||||
|
currentArticle = '';
|
||||||
|
el.textContent = 'Артикул: —';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentArticle() {
|
||||||
|
return currentArticle || '';
|
||||||
|
}
|
||||||
|
|
||||||
function triggerAutoSave() {
|
function triggerAutoSave() {
|
||||||
// Debounce autosave - wait 1 second after last change
|
// Debounce autosave - wait 1 second after last change
|
||||||
if (autoSaveTimeout) {
|
if (autoSaveTimeout) {
|
||||||
@@ -1751,6 +1849,9 @@ async function saveConfig(showNotification = true) {
|
|||||||
custom_price: customPrice,
|
custom_price: customPrice,
|
||||||
notes: '',
|
notes: '',
|
||||||
server_count: serverCountValue,
|
server_count: serverCountValue,
|
||||||
|
server_model: serverModelForQuote,
|
||||||
|
support_code: supportCode,
|
||||||
|
article: getCurrentArticle(),
|
||||||
pricelist_id: selectedPricelistIds.estimate,
|
pricelist_id: selectedPricelistIds.estimate,
|
||||||
only_in_stock: onlyInStock
|
only_in_stock: onlyInStock
|
||||||
})
|
})
|
||||||
@@ -1795,17 +1896,19 @@ async function exportCSV() {
|
|||||||
...item,
|
...item,
|
||||||
unit_price: getDisplayPrice(item),
|
unit_price: getDisplayPrice(item),
|
||||||
}));
|
}));
|
||||||
|
const article = getCurrentArticle();
|
||||||
const resp = await fetch('/api/export/csv', {
|
const resp = await fetch('/api/export/csv', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({items: exportItems, name: configName, project_uuid: projectUUID})
|
body: JSON.stringify({items: exportItems, name: configName, project_uuid: projectUUID, article: article})
|
||||||
});
|
});
|
||||||
|
|
||||||
const blob = await resp.blob();
|
const blob = await resp.blob();
|
||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = getFilenameFromResponse(resp) || (configName || 'config') + '.csv';
|
const articleForName = article || 'BOM';
|
||||||
|
a.download = getFilenameFromResponse(resp) || ((configName || 'config') + ' ' + articleForName + '.csv');
|
||||||
a.click();
|
a.click();
|
||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
|
|||||||
Reference in New Issue
Block a user