Compare commits

...

8 Commits
v2.24 ... v2.27

Author SHA1 Message Date
Mikhail Chusavitin
50f0e4f76f feat: индикатор присутствия в конфигурациях (иконка глаза)
Открытые конфигурации фиксируются в локальном SQLite (app_settings) и
передаются на сервер через qt_client_schema_state.open_config_uuids при
каждом цикле синхронизации. Списки конфигураций обогащаются полем viewers,
в таблицах отображается иконка глаза с подсказкой при наличии других
пользователей, открывших эту конфигурацию.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 00:56:06 +03:00
Mikhail Chusavitin
9601619d1b docs: release notes v2.26
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 16:22:54 +03:00
Mikhail Chusavitin
f24584f65c fix: лоты без категории в прайслисте не блокируют сборку артикула
ResolveLotCategoriesStrict переименован в ResolveLotCategories и лишён
строгости: лоты, отсутствующие в прайслисте или с пустой lot_category,
просто пропускаются — партномер из них не собирается. Ранее любой
«незнакомый» лот возвращал ошибку и блокировал сохранение конфига.

Удалены ErrMissingCategoryForLot, MissingCategoryForLotError и
fallback через local_components (противоречил cc72052).

resolvePricelistID: если прайслист отсутствует локально после синка —
fallback на последний активный вместо ошибки.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 16:21:45 +03:00
Mikhail Chusavitin
f6766ce6b8 refactor: удалить мёртвый код qt_lot_metadata
Таблица qt_lot_metadata не использовалась в рантайме —
ни один репозиторий/сервис/хендлер к ней не обращался.

- удалён models/metadata.go (LotMetadata, Specs, PriceMethod, PriceFreshness)
- удалена LocalToComponent() из localdb/converters.go
- убран &LotMetadata{} из AutoMigrate
- убраны мёртвые поля PriceFreshness/PopularityScore/Specs из ComponentView

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 11:56:59 +03:00
Mikhail Chusavitin
464d2a48d7 docs: release notes v2.25
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 09:12:03 +03:00
Mikhail Chusavitin
b23eb1d75a fix: регистронезависимое сопоставление BOM↔корзина (фронт + CSV)
LOT из BOM-маппинга мог быть в смешанном регистре, а корзина — в каноничном
UPPERCASE, из-за чего позиции дублировались (в таблице «Цена покупки» и в
экспорте CSV).

- localdb.NormalizeLotMappings: единая каноничная нормализация LOT-маппингов
  (UPPERCASE + схлопывание дублей с суммированием qty). Убраны две разошедшиеся
  копии normalizeLotMappings (handlers и services — последняя только тримила,
  что и было причиной бага в CSV).
- export.go: BOM-ветка использует общую функцию + канонизирует LOT корзины
  для coverage/lookup. Удалена мёртвая computeMappingTotal.
- index.html (renderPricingTab): сопоставление/дедуп LOT через каноничный ключ
  UPPERCASE; аксессоры _getRowBaseLot/_getRowAllocations возвращают канон.
- Добавлен регресс-тест TestNormalizeLotMappings_*.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 08:59:50 +03:00
Mikhail Chusavitin
cc72052c8a refactor: единый источник категории LOT — local_pricelist_items.lot_category
Удалён мёртвый серверный слой управления компонентами/категориями,
который дублировал источники категории LOT. В рантайме категория всегда
берётся из local_pricelist_items.lot_category (наполняется синком из
qt_pricelist_items.lot_category).

Удалено:
- repository: UnifiedRepo/DataSource, ComponentRepository, CategoryRepository
- services: старый ConfigurationService (заменён LocalConfigurationService),
  ComponentService, ComponentToLocal, ImportFromLot, ParsePartNumber
- quote.go: недостижимый online-блок (qt_categories) + componentRepo/
  pricingService/priceResolver

Сохранены живые типы: models.Category/DefaultCategories (для /api/categories),
ComponentView/ComponentListResult, CreateConfigRequest/ArticlePreviewRequest/
ConfigurationGetter/ErrConfig*.

bible-local/03-database.md: зафиксирован единственный источник категории LOT;
qt_categories/qt_lot_metadata перенесены в server-side only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 21:21:34 +03:00
Mikhail Chusavitin
687ab99d85 docs: release notes v2.24
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 12:06:22 +03:00
29 changed files with 434 additions and 1882 deletions

View File

@@ -25,6 +25,7 @@ Rules:
- cache tables may be rebuilt if local migration recovery requires it; - cache tables may be rebuilt if local migration recovery requires it;
- user-authored tables must not be dropped as a recovery shortcut; - user-authored tables must not be dropped as a recovery shortcut;
- `local_pricelist_items` is the only valid runtime source of prices and component catalog; do not add a separate component cache table; - `local_pricelist_items` is the only valid runtime source of prices and component catalog; do not add a separate component cache table;
- `local_pricelist_items.lot_category` is the single source of a LOT's category at runtime (populated by sync from `qt_pricelist_items.lot_category`); do not derive category from a lot_name prefix or from `qt_categories`/`qt_lot_metadata`;
- configuration `items` and `vendor_spec` are stored as JSON payloads inside configuration rows; - configuration `items` and `vendor_spec` are stored as JSON payloads inside configuration rows;
- `local_components` table has been removed; any reference to it is dead code. - `local_components` table has been removed; any reference to it is dead code.
@@ -35,8 +36,6 @@ MariaDB is the central sync database (`RFQ_LOG`). Final schema as of 2026-04-15.
### QuoteForge tables (qt_*) ### QuoteForge tables (qt_*)
Runtime read: Runtime read:
- `qt_categories` — pricelist categories (note: `name`/`name_ru` columns being removed; QF does not use them)
- `qt_lot_metadata` — component metadata, price settings
- `qt_pricelists` — pricelist headers (source: estimate / warehouse / competitor) - `qt_pricelists` — pricelist headers (source: estimate / warehouse / competitor)
- `qt_pricelist_items` — pricelist rows - `qt_pricelist_items` — pricelist rows
- `qt_partnumber_books` — partnumber book headers - `qt_partnumber_books` — partnumber book headers
@@ -53,6 +52,8 @@ Insert-only tracking:
- `qt_vendor_partnumber_seen` — vendor partnumbers encountered during sync; `lot_suggestion` column updated when user manually maps PN → LOT in vendor-spec UI - `qt_vendor_partnumber_seen` — vendor partnumbers encountered during sync; `lot_suggestion` column updated when user manually maps PN → LOT in vendor-spec UI
Server-side only (not queried by client runtime): Server-side only (not queried by client runtime):
- `qt_categories` — pricelist category registry; QF runtime serves category lists for the UI from `models.DefaultCategories` (Go) overlaid with categories present in `local_pricelist_items`, not from this table. `name`/`name_ru` columns being removed.
- `qt_lot_metadata` — component metadata / price settings; the Go server-side component/category management layer (`ComponentRepository`, `CategoryRepository`, `ComponentService`) was removed — no client code reads this table
- `qt_component_usage_stats` — aggregated component popularity stats (written by server jobs) - `qt_component_usage_stats` — aggregated component popularity stats (written by server jobs)
- `qt_pricing_alerts` — price anomaly alerts (models exist in Go; feature disabled in runtime) - `qt_pricing_alerts` — price anomaly alerts (models exist in Go; feature disabled in runtime)
- `qt_schema_migrations` — server migration history (applied via `go run ./cmd/qfs -migrate`) - `qt_schema_migrations` — server migration history (applied via `go run ./cmd/qfs -migrate`)

View File

@@ -677,8 +677,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
var projectService *services.ProjectService var projectService *services.ProjectService
syncService = sync.NewService(connMgr, local) syncService = sync.NewService(connMgr, local)
componentService := services.NewComponentService(nil, nil) quoteService := services.NewQuoteService(nil, local)
quoteService := services.NewQuoteService(nil, nil, local, nil)
exportService := services.NewExportService(cfg.Export, local) exportService := services.NewExportService(cfg.Export, local)
// isOnline function for local-first architecture // isOnline function for local-first architecture
@@ -775,7 +774,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
templatesPath := filepath.Join("web", "templates") templatesPath := filepath.Join("web", "templates")
// Handlers // Handlers
componentHandler := handlers.NewComponentHandler(componentService, local) componentHandler := handlers.NewComponentHandler(local)
quoteHandler := handlers.NewQuoteHandler(quoteService) quoteHandler := handlers.NewQuoteHandler(quoteService)
exportHandler := handlers.NewExportHandler(exportService, configService, projectService, dbUsername) exportHandler := handlers.NewExportHandler(exportService, configService, projectService, dbUsername)
pricelistHandler := handlers.NewPricelistHandler(local) pricelistHandler := handlers.NewPricelistHandler(local)
@@ -997,8 +996,27 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
return return
} }
uuids := make([]string, len(cfgs))
for i, cfg := range cfgs {
uuids[i] = cfg.UUID
}
viewers, _ := syncService.ListActiveViewersByConfigUUIDs(uuids)
type cfgRow struct {
models.Configuration
Viewers []string `json:"viewers"`
}
rows := make([]cfgRow, len(cfgs))
for i, cfg := range cfgs {
v := viewers[cfg.UUID]
if v == nil {
v = []string{}
}
rows[i] = cfgRow{Configuration: cfg, Viewers: v}
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"configurations": cfgs, "configurations": rows,
"total": total, "total": total,
"page": page, "page": page,
"per_page": perPage, "per_page": perPage,
@@ -1333,6 +1351,16 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
} }
c.JSON(http.StatusOK, config) c.JSON(http.StatusOK, config)
}) })
configs.POST("/:uuid/presence", func(c *gin.Context) {
_ = local.AddOpenConfigUUID(c.Param("uuid"))
c.JSON(http.StatusOK, gin.H{"ok": true})
})
configs.DELETE("/:uuid/presence", func(c *gin.Context) {
_ = local.RemoveOpenConfigUUID(c.Param("uuid"))
c.JSON(http.StatusOK, gin.H{"ok": true})
})
} }
projects := api.Group("/projects") projects := api.Group("/projects")
@@ -1673,8 +1701,32 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
} }
return return
} }
projUUIDs := make([]string, len(result.Configs))
for i, cfg := range result.Configs {
projUUIDs[i] = cfg.UUID
}
projViewers, _ := syncService.ListActiveViewersByConfigUUIDs(projUUIDs)
type projCfgRow struct {
models.Configuration
Viewers []string `json:"viewers"`
}
projRows := make([]projCfgRow, len(result.Configs))
for i, cfg := range result.Configs {
v := projViewers[cfg.UUID]
if v == nil {
v = []string{}
}
projRows[i] = projCfgRow{Configuration: cfg, Viewers: v}
}
c.Header("X-Config-Status", status) c.Header("X-Config-Status", status)
c.JSON(http.StatusOK, result) c.JSON(http.StatusOK, gin.H{
"project_uuid": result.ProjectUUID,
"configurations": projRows,
"total": result.Total,
})
}) })
projects.PATCH("/:uuid/configs/reorder", func(c *gin.Context) { projects.PATCH("/:uuid/configs/reorder", func(c *gin.Context) {

View File

@@ -1,31 +1,12 @@
package article package article
import ( import (
"errors"
"fmt" "fmt"
"strings" "strings"
"git.mchus.pro/mchus/quoteforge/internal/localdb" "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 type Group string
const ( const (
@@ -61,9 +42,10 @@ func GroupForLotCategory(cat string) (group Group, ok bool) {
} }
} }
// ResolveLotCategoriesStrict resolves categories for lotNames using local_pricelist_items.lot_category // ResolveLotCategories returns lot_category for each lotName found in local_pricelist_items
// for a given server pricelist id. If any lot is missing or has empty category, returns an error. // for the given server pricelist. Lots not found in the pricelist are omitted from the result —
func ResolveLotCategoriesStrict(local *localdb.LocalDB, serverPricelistID uint, lotNames []string) (map[string]string, error) { // callers must treat a missing key as "no category" and skip that lot.
func ResolveLotCategories(local *localdb.LocalDB, serverPricelistID uint, lotNames []string) (map[string]string, error) {
if local == nil { if local == nil {
return nil, fmt.Errorf("local db is nil") return nil, fmt.Errorf("local db is nil")
} }
@@ -71,30 +53,8 @@ func ResolveLotCategoriesStrict(local *localdb.LocalDB, serverPricelistID uint,
if err != nil { if err != nil {
return nil, err return nil, err
} }
missing := make([]string, 0) for lot, cat := range cats {
for _, lot := range lotNames { cats[lot] = strings.TrimSpace(cat)
cat := strings.TrimSpace(cats[lot])
if cat == "" {
missing = append(missing, lot)
continue
}
cats[lot] = cat
}
if len(missing) > 0 {
fallback, err := local.GetLocalComponentCategoriesByLotNames(missing)
if err != nil {
return nil, err
}
for _, lot := range missing {
if cat := strings.TrimSpace(fallback[lot]); cat != "" {
cats[lot] = cat
}
}
for _, lot := range missing {
if strings.TrimSpace(cats[lot]) == "" {
return nil, &MissingCategoryForLotError{LotName: lot}
}
}
} }
return cats, nil return cats, nil
} }

View File

@@ -1,7 +1,6 @@
package article package article
import ( import (
"errors"
"path/filepath" "path/filepath"
"testing" "testing"
"time" "time"
@@ -9,7 +8,7 @@ import (
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
) )
func TestResolveLotCategoriesStrict_MissingCategoryReturnsError(t *testing.T) { func TestResolveLotCategories_MissingLotOmitted(t *testing.T) {
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db")) local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
if err != nil { if err != nil {
t.Fatalf("init local db: %v", err) t.Fatalf("init local db: %v", err)
@@ -36,73 +35,60 @@ func TestResolveLotCategoriesStrict_MissingCategoryReturnsError(t *testing.T) {
t.Fatalf("save local items: %v", err) t.Fatalf("save local items: %v", err)
} }
_, err = ResolveLotCategoriesStrict(local, 1, []string{"CPU_A"}) cats, err := ResolveLotCategories(local, 1, []string{"CPU_A", "UNKNOWN"})
if err == nil { if err != nil {
t.Fatalf("expected error") t.Fatalf("unexpected error: %v", err)
} }
if !errors.Is(err, ErrMissingCategoryForLot) { if cats["CPU_A"] != "" {
t.Fatalf("expected ErrMissingCategoryForLot, got %v", err) t.Fatalf("expected empty category for lot with blank lot_category, got %q", cats["CPU_A"])
}
if _, ok := cats["UNKNOWN"]; ok {
t.Fatalf("expected UNKNOWN lot to be omitted from result")
} }
} }
func TestResolveLotCategoriesStrict_FallbackToLatestPricelist(t *testing.T) { func TestResolveLotCategories_ReturnsKnownCategories(t *testing.T) {
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db")) local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
if err != nil { if err != nil {
t.Fatalf("init local db: %v", err) t.Fatalf("init local db: %v", err)
} }
t.Cleanup(func() { _ = local.Close() }) t.Cleanup(func() { _ = local.Close() })
// Older pricelist used by the configuration — CPU_B has no category here
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{ if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
ServerID: 2, ServerID: 1,
Source: "estimate", Source: "estimate",
Version: "S-2026-02-11-002", Version: "S-2026-02-11-001",
Name: "old", Name: "test",
IsActive: false,
CreatedAt: time.Now().Add(-time.Hour),
SyncedAt: time.Now().Add(-time.Hour),
}); err != nil {
t.Fatalf("save old pricelist: %v", err)
}
oldPL, err := local.GetLocalPricelistByServerID(2)
if err != nil {
t.Fatalf("get old pricelist: %v", err)
}
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
{PricelistID: oldPL.ID, LotName: "CPU_B", LotCategory: "", Price: 10},
}); err != nil {
t.Fatalf("save old pricelist items: %v", err)
}
// Newer active pricelist — CPU_B has category set
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
ServerID: 3,
Source: "estimate",
Version: "S-2026-02-11-003",
Name: "latest",
IsActive: true, IsActive: true,
CreatedAt: time.Now(), CreatedAt: time.Now(),
SyncedAt: time.Now(), SyncedAt: time.Now(),
}); err != nil { }); err != nil {
t.Fatalf("save latest pricelist: %v", err) t.Fatalf("save pricelist: %v", err)
} }
latestPL, err := local.GetLocalPricelistByServerID(3) pl, err := local.GetLocalPricelistByServerID(1)
if err != nil { if err != nil {
t.Fatalf("get latest pricelist: %v", err) t.Fatalf("get pricelist: %v", err)
} }
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{ if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
{PricelistID: latestPL.ID, LotName: "CPU_B", LotCategory: "CPU", Price: 10}, {PricelistID: pl.ID, LotName: "CPU_B", LotCategory: "CPU", Price: 10},
{PricelistID: pl.ID, LotName: "MB_X", LotCategory: "MB", Price: 5},
}); err != nil { }); err != nil {
t.Fatalf("save latest pricelist items: %v", err) t.Fatalf("save items: %v", err)
} }
cats, err := ResolveLotCategoriesStrict(local, 2, []string{"CPU_B"}) cats, err := ResolveLotCategories(local, 1, []string{"CPU_B", "MB_X", "NOT_IN_PL"})
if err != nil { if err != nil {
t.Fatalf("expected fallback, got error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
if cats["CPU_B"] != "CPU" { if cats["CPU_B"] != "CPU" {
t.Fatalf("expected CPU, got %q", cats["CPU_B"]) t.Fatalf("expected CPU, got %q", cats["CPU_B"])
} }
if cats["MB_X"] != "MB" {
t.Fatalf("expected MB, got %q", cats["MB_X"])
}
if _, ok := cats["NOT_IN_PL"]; ok {
t.Fatalf("expected NOT_IN_PL to be omitted")
}
} }
func TestGroupForLotCategory(t *testing.T) { func TestGroupForLotCategory(t *testing.T) {

View File

@@ -55,7 +55,7 @@ func Build(local *localdb.LocalDB, items []models.ConfigItem, opts BuildOptions)
return BuildResult{}, fmt.Errorf("pricelist_id required for article") return BuildResult{}, fmt.Errorf("pricelist_id required for article")
} }
cats, err := ResolveLotCategoriesStrict(local, *opts.ServerPricelist, lotNames) cats, err := ResolveLotCategories(local, *opts.ServerPricelist, lotNames)
if err != nil { if err != nil {
return BuildResult{}, err return BuildResult{}, err
} }

View File

@@ -7,20 +7,17 @@ import (
"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/repository"
"git.mchus.pro/mchus/quoteforge/internal/services" "git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
type ComponentHandler struct { type ComponentHandler struct {
componentService *services.ComponentService localDB *localdb.LocalDB
localDB *localdb.LocalDB
} }
func NewComponentHandler(componentService *services.ComponentService, localDB *localdb.LocalDB) *ComponentHandler { func NewComponentHandler(localDB *localdb.LocalDB) *ComponentHandler {
return &ComponentHandler{ return &ComponentHandler{
componentService: componentService, localDB: localDB,
localDB: localDB,
} }
} }
@@ -34,17 +31,10 @@ func (h *ComponentHandler) List(c *gin.Context) {
perPage = 20 perPage = 20
} }
filter := repository.ComponentFilter{
Category: c.Query("category"),
Search: c.Query("search"),
HasPrice: c.Query("has_price") == "true",
ExcludeHidden: c.Query("include_hidden") != "true", // По умолчанию скрытые не показываются
}
localFilter := localdb.ComponentFilter{ localFilter := localdb.ComponentFilter{
Category: filter.Category, Category: c.Query("category"),
Search: filter.Search, Search: c.Query("search"),
HasPrice: filter.HasPrice, HasPrice: c.Query("has_price") == "true",
} }
offset := (page - 1) * perPage offset := (page - 1) * perPage
localComps, total, err := h.localDB.ListComponents(localFilter, offset, perPage) localComps, total, err := h.localDB.ListComponents(localFilter, offset, perPage)

View File

@@ -6,7 +6,6 @@ import (
"net/http" "net/http"
"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/repository" "git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services" "git.mchus.pro/mchus/quoteforge/internal/services"
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync" syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
@@ -100,7 +99,7 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
body.VendorSpec[i].SortOrder = (i + 1) * 10 body.VendorSpec[i].SortOrder = (i + 1) * 10
} }
// Persist canonical LOT mapping only. // Persist canonical LOT mapping only.
body.VendorSpec[i].LotMappings = normalizeLotMappings(body.VendorSpec[i].LotMappings) body.VendorSpec[i].LotMappings = localdb.NormalizeLotMappings(body.VendorSpec[i].LotMappings)
body.VendorSpec[i].ResolvedLotName = "" body.VendorSpec[i].ResolvedLotName = ""
body.VendorSpec[i].ResolutionSource = "" body.VendorSpec[i].ResolutionSource = ""
body.VendorSpec[i].ManualLotSuggestion = "" body.VendorSpec[i].ManualLotSuggestion = ""
@@ -165,39 +164,6 @@ func (h *VendorSpecHandler) pushLotSuggestions(spec []localdb.VendorSpecItem) {
} }
} }
func normalizeLotMappings(in []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping {
if len(in) == 0 {
return nil
}
merged := make(map[string]int, len(in))
order := make([]string, 0, len(in))
for _, m := range in {
lot := models.NormalizeLotName(m.LotName)
if lot == "" {
continue
}
qty := m.QuantityPerPN
if qty < 1 {
qty = 1
}
if _, exists := merged[lot]; !exists {
order = append(order, lot)
}
merged[lot] += qty
}
out := make([]localdb.VendorSpecLotMapping, 0, len(order))
for _, lot := range order {
out = append(out, localdb.VendorSpecLotMapping{
LotName: lot,
QuantityPerPN: merged[lot],
})
}
if len(out) == 0 {
return nil
}
return out
}
// ResolveVendorSpec resolves vendor PN → LOT without modifying the cart. // ResolveVendorSpec resolves vendor PN → LOT without modifying the cart.
// POST /api/configs/:uuid/vendor-spec/resolve // POST /api/configs/:uuid/vendor-spec/resolve
func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) { func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) {

View File

@@ -6,6 +6,42 @@ import (
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
) )
// NormalizeLotMappings is the single canonical normalizer for vendor BOM LOT
// mappings. LOT names are canonicalized to their uppercase form (see
// models.NormalizeLotName) so that all BOM↔cart matching is case-insensitive,
// duplicate LOTs are merged (summing quantity-per-PN), and quantities are at
// least 1. Returns nil for an empty result. Both the persistence path
// (handlers) and the CSV export path must use this — do not reimplement it.
func NormalizeLotMappings(in []VendorSpecLotMapping) []VendorSpecLotMapping {
if len(in) == 0 {
return nil
}
merged := make(map[string]int, len(in))
order := make([]string, 0, len(in))
for _, m := range in {
lot := models.NormalizeLotName(m.LotName)
if lot == "" {
continue
}
qty := m.QuantityPerPN
if qty < 1 {
qty = 1
}
if _, exists := merged[lot]; !exists {
order = append(order, lot)
}
merged[lot] += qty
}
if len(order) == 0 {
return nil
}
out := make([]VendorSpecLotMapping, 0, len(order))
for _, lot := range order {
out = append(out, VendorSpecLotMapping{LotName: lot, QuantityPerPN: merged[lot]})
}
return out
}
// ConfigurationToLocal converts models.Configuration to LocalConfiguration // ConfigurationToLocal converts models.Configuration to LocalConfiguration
func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration { func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
items := make(LocalConfigItems, len(cfg.Items)) items := make(LocalConfigItems, len(cfg.Items))
@@ -294,41 +330,3 @@ func LocalToPricelistItem(local *LocalPricelistItem, serverPricelistID uint) *mo
} }
} }
// ComponentToLocal converts models.LotMetadata to LocalComponent
func ComponentToLocal(meta *models.LotMetadata) *LocalComponent {
var lotDesc string
var category string
if meta.Lot != nil {
lotDesc = meta.Lot.LotDescription
}
// Extract category from lot_name (e.g., "CPU_AMD_9654" -> "CPU")
if len(meta.LotName) > 0 {
for i, ch := range meta.LotName {
if ch == '_' {
category = meta.LotName[:i]
break
}
}
}
return &LocalComponent{
LotName: meta.LotName,
LotDescription: lotDesc,
Category: category,
Model: meta.Model,
}
}
// LocalToComponent converts LocalComponent to models.LotMetadata
func LocalToComponent(local *LocalComponent) *models.LotMetadata {
return &models.LotMetadata{
LotName: local.LotName,
Model: local.Model,
Lot: &models.Lot{
LotName: local.LotName,
LotDescription: local.LotDescription,
},
}
}

View File

@@ -6,6 +6,36 @@ import (
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
) )
func TestNormalizeLotMappings_CaseInsensitiveMerge(t *testing.T) {
in := []VendorSpecLotMapping{
{LotName: "cpu_intel_6960p", QuantityPerPN: 1},
{LotName: "CPU_INTEL_6960P", QuantityPerPN: 2},
{LotName: " ps_5200w_Titanium ", QuantityPerPN: 0},
{LotName: "", QuantityPerPN: 5},
}
out := NormalizeLotMappings(in)
if len(out) != 2 {
t.Fatalf("expected 2 merged mappings, got %d: %+v", len(out), out)
}
if out[0].LotName != "CPU_INTEL_6960P" || out[0].QuantityPerPN != 3 {
t.Fatalf("expected CPU_INTEL_6960P qty 3, got %+v", out[0])
}
if out[1].LotName != "PS_5200W_TITANIUM" || out[1].QuantityPerPN != 1 {
t.Fatalf("expected PS_5200W_TITANIUM qty 1 (clamped), got %+v", out[1])
}
}
func TestNormalizeLotMappings_Empty(t *testing.T) {
if NormalizeLotMappings(nil) != nil {
t.Fatal("expected nil for empty input")
}
if NormalizeLotMappings([]VendorSpecLotMapping{{LotName: " "}}) != nil {
t.Fatal("expected nil when all entries blank")
}
}
func TestPricelistItemToLocal_PreservesLotCategory(t *testing.T) { func TestPricelistItemToLocal_PreservesLotCategory(t *testing.T) {
item := &models.PricelistItem{ item := &models.PricelistItem{
LotName: "CPU_A", LotName: "CPU_A",

View File

@@ -1235,6 +1235,67 @@ func (l *LocalDB) GetLastComponentSyncError() string {
} }
const openConfigUUIDsKey = "open_config_uuids"
// GetOpenConfigUUIDs returns UUIDs of all configurations currently open in the configurator.
func (l *LocalDB) GetOpenConfigUUIDs() []string {
value, ok := l.getAppSettingValue(openConfigUUIDsKey)
if !ok || value == "" {
return nil
}
var uuids []string
if err := json.Unmarshal([]byte(value), &uuids); err != nil {
return nil
}
return uuids
}
// AddOpenConfigUUID records that a configuration is open in the configurator.
func (l *LocalDB) AddOpenConfigUUID(uuid string) error {
uuids := l.GetOpenConfigUUIDs()
for _, u := range uuids {
if u == uuid {
return nil
}
}
uuids = append(uuids, uuid)
raw, err := json.Marshal(uuids)
if err != nil {
return err
}
return l.db.Exec(`
INSERT INTO app_settings (key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
`, openConfigUUIDsKey, string(raw), time.Now().Format(time.RFC3339)).Error
}
// RemoveOpenConfigUUID records that a configuration is no longer open in the configurator.
func (l *LocalDB) RemoveOpenConfigUUID(uuid string) error {
uuids := l.GetOpenConfigUUIDs()
filtered := uuids[:0]
for _, u := range uuids {
if u != uuid {
filtered = append(filtered, u)
}
}
var raw []byte
var err error
if len(filtered) == 0 {
raw = []byte("[]")
} else {
raw, err = json.Marshal(filtered)
if err != nil {
return err
}
}
return l.db.Exec(`
INSERT INTO app_settings (key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
`, openConfigUUIDsKey, string(raw), time.Now().Format(time.RFC3339)).Error
}
// CountLocalPricelists returns the number of local pricelists // CountLocalPricelists returns the number of local pricelists
func (l *LocalDB) CountLocalPricelists() int64 { func (l *LocalDB) CountLocalPricelists() int64 {
var count int64 var count int64

View File

@@ -1,92 +0,0 @@
package models
import (
"database/sql/driver"
"encoding/json"
"errors"
"time"
)
type PriceMethod string
const (
PriceMethodManual PriceMethod = "manual"
PriceMethodMedian PriceMethod = "median"
PriceMethodAverage PriceMethod = "average"
PriceMethodWeightedMedian PriceMethod = "weighted_median"
)
type Specs map[string]interface{}
func (s Specs) Value() (driver.Value, error) {
return json.Marshal(s)
}
func (s *Specs) Scan(value interface{}) error {
if value == nil {
*s = make(Specs)
return nil
}
bytes, ok := value.([]byte)
if !ok {
return errors.New("type assertion to []byte failed")
}
return json.Unmarshal(bytes, s)
}
type LotMetadata struct {
LotName string `gorm:"column:lot_name;primaryKey;size:255" json:"lot_name"`
CategoryID *uint `gorm:"column:category_id" json:"category_id"`
Model string `gorm:"size:100" json:"model"`
Specs Specs `gorm:"type:json" json:"specs"`
CurrentPrice *float64 `gorm:"type:decimal(12,2)" json:"current_price"`
PriceMethod PriceMethod `gorm:"type:enum('manual','median','average','weighted_median');default:'median'" json:"price_method"`
PricePeriodDays int `gorm:"default:90" json:"price_period_days"`
PriceCoefficient float64 `gorm:"type:decimal(5,2);default:0" json:"price_coefficient"`
ManualPrice *float64 `gorm:"type:decimal(12,2)" json:"manual_price"`
PriceUpdatedAt *time.Time `json:"price_updated_at"`
RequestCount int `gorm:"default:0" json:"request_count"`
LastRequestDate *time.Time `gorm:"type:date" json:"last_request_date"`
PopularityScore float64 `gorm:"type:decimal(10,4);default:0" json:"popularity_score"`
MetaPrices string `gorm:"size:1000" json:"meta_prices"`
MetaMethod string `gorm:"size:20" json:"meta_method"`
MetaPeriodDays int `gorm:"default:90" json:"meta_period_days"`
IsHidden bool `gorm:"default:false" json:"is_hidden"`
// Relations
Lot *Lot `gorm:"foreignKey:LotName;references:LotName" json:"lot,omitempty"`
Category *Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
}
func (LotMetadata) TableName() string {
return "qt_lot_metadata"
}
type PriceFreshness string
const (
FreshnessFresh PriceFreshness = "fresh"
FreshnessNormal PriceFreshness = "normal"
FreshnessStale PriceFreshness = "stale"
FreshnessCritical PriceFreshness = "critical"
)
func (m *LotMetadata) GetPriceFreshness(greenDays, yellowDays, redDays, minQuotes int) PriceFreshness {
if m.CurrentPrice == nil || *m.CurrentPrice == 0 {
return FreshnessCritical
}
if m.PriceUpdatedAt == nil {
return FreshnessCritical
}
daysSince := int(time.Since(*m.PriceUpdatedAt).Hours() / 24)
if daysSince < greenDays && m.RequestCount >= minQuotes {
return FreshnessFresh
} else if daysSince < yellowDays {
return FreshnessNormal
} else if daysSince < redDays {
return FreshnessStale
}
return FreshnessCritical
}

View File

@@ -11,7 +11,6 @@ import (
func AllModels() []interface{} { func AllModels() []interface{} {
return []interface{}{ return []interface{}{
&Category{}, &Category{},
&LotMetadata{},
&Project{}, &Project{},
&Configuration{}, &Configuration{},
&Pricelist{}, &Pricelist{},

View File

@@ -1,76 +0,0 @@
package repository
import (
"git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/gorm"
)
type CategoryRepository struct {
db *gorm.DB
}
func NewCategoryRepository(db *gorm.DB) *CategoryRepository {
return &CategoryRepository{db: db}
}
func (r *CategoryRepository) GetAll() ([]models.Category, error) {
var categories []models.Category
err := r.db.Order("display_order ASC").Find(&categories).Error
return categories, err
}
func (r *CategoryRepository) GetByCode(code string) (*models.Category, error) {
var category models.Category
err := r.db.Where("code = ?", code).First(&category).Error
if err != nil {
return nil, err
}
return &category, nil
}
func (r *CategoryRepository) GetByID(id uint) (*models.Category, error) {
var category models.Category
err := r.db.First(&category, id).Error
if err != nil {
return nil, err
}
return &category, nil
}
// CreateIfNotExists creates a new category if it doesn't exist, returns existing one if it does
func (r *CategoryRepository) CreateIfNotExists(code string) (*models.Category, error) {
// Try to find existing
existing, err := r.GetByCode(code)
if err == nil {
return existing, nil
}
// Get max display order to put new category at the end
var maxOrder int
r.db.Model(&models.Category{}).Select("COALESCE(MAX(display_order), 0)").Scan(&maxOrder)
// Create new category
newCat := &models.Category{
Code: code,
Name: code, // Use code as name initially
NameRu: code,
DisplayOrder: maxOrder + 1,
IsRequired: false,
}
if err := r.db.Create(newCat).Error; err != nil {
return nil, err
}
return newCat, nil
}
// Create creates a new category
func (r *CategoryRepository) Create(category *models.Category) error {
return r.db.Create(category).Error
}
// Update updates an existing category
func (r *CategoryRepository) Update(category *models.Category) error {
return r.db.Save(category).Error
}

View File

@@ -1,140 +0,0 @@
package repository
import (
"time"
"git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/gorm"
)
type ComponentRepository struct {
db *gorm.DB
}
func NewComponentRepository(db *gorm.DB) *ComponentRepository {
return &ComponentRepository{db: db}
}
type ComponentFilter struct {
Category string
Search string
HasPrice bool
ExcludeHidden bool
SortField string
SortDir string
}
func (r *ComponentRepository) List(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) {
var components []models.LotMetadata
var total int64
query := r.db.Model(&models.LotMetadata{}).
Preload("Lot").
Preload("Category")
if filter.Category != "" {
query = query.Joins("JOIN qt_categories ON qt_lot_metadata.category_id = qt_categories.id").
Where("qt_categories.code = ?", filter.Category)
}
if filter.Search != "" {
search := "%" + filter.Search + "%"
query = query.Where("lot_name LIKE ? OR model LIKE ?", search, search)
}
if filter.HasPrice {
query = query.Where("current_price IS NOT NULL AND current_price > 0")
}
if filter.ExcludeHidden {
query = query.Where("is_hidden = ? OR is_hidden IS NULL", false)
}
query.Count(&total)
// Apply sorting
sortDir := "ASC"
if filter.SortDir == "desc" {
sortDir = "DESC"
}
switch filter.SortField {
case "popularity_score":
query = query.Order("popularity_score " + sortDir)
case "current_price":
query = query.Order("CASE WHEN current_price IS NULL OR current_price = 0 THEN 1 ELSE 0 END").
Order("current_price " + sortDir)
case "lot_name":
query = query.Order("lot_name " + sortDir)
default:
// Default: sort by popularity, no price goes last
query = query.
Order("CASE WHEN current_price IS NULL OR current_price = 0 THEN 1 ELSE 0 END").
Order("popularity_score DESC")
}
err := query.
Offset(offset).
Limit(limit).
Find(&components).Error
return components, total, err
}
func (r *ComponentRepository) GetByLotName(lotName string) (*models.LotMetadata, error) {
var component models.LotMetadata
err := r.db.
Preload("Lot").
Preload("Category").
Where("lot_name = ?", lotName).
First(&component).Error
if err != nil {
return nil, err
}
return &component, nil
}
func (r *ComponentRepository) GetMultiple(lotNames []string) ([]models.LotMetadata, error) {
var components []models.LotMetadata
err := r.db.
Preload("Lot").
Preload("Category").
Where("lot_name IN ?", lotNames).
Find(&components).Error
return components, err
}
func (r *ComponentRepository) Update(component *models.LotMetadata) error {
return r.db.Save(component).Error
}
func (r *ComponentRepository) DB() *gorm.DB {
return r.db
}
func (r *ComponentRepository) Create(component *models.LotMetadata) error {
return r.db.Create(component).Error
}
func (r *ComponentRepository) IncrementRequestCount(lotName string) error {
now := time.Now()
return r.db.Model(&models.LotMetadata{}).
Where("lot_name = ?", lotName).
Updates(map[string]interface{}{
"request_count": gorm.Expr("request_count + 1"),
"last_request_date": now,
}).Error
}
// GetAllLots returns all lots from the existing lot table
func (r *ComponentRepository) GetAllLots() ([]models.Lot, error) {
var lots []models.Lot
err := r.db.Find(&lots).Error
return lots, err
}
// GetLotsWithoutMetadata returns lots that don't have qt_lot_metadata entries
func (r *ComponentRepository) GetLotsWithoutMetadata() ([]models.Lot, error) {
var lots []models.Lot
err := r.db.
Where("lot_name NOT IN (SELECT lot_name FROM qt_lot_metadata)").
Find(&lots).Error
return lots, err
}

View File

@@ -1,393 +0,0 @@
package repository
import (
"encoding/json"
"fmt"
"time"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/gorm"
)
// DataSource defines the unified interface for data access
// It abstracts whether data comes from MariaDB (online) or SQLite (offline)
type DataSource interface {
// Components
GetComponents(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error)
GetComponent(lotName string) (*models.LotMetadata, error)
// Configurations
SaveConfiguration(cfg *models.Configuration) error
GetConfigurations(ownerUsername string) ([]models.Configuration, error)
GetConfigurationByUUID(uuid string) (*models.Configuration, error)
DeleteConfiguration(uuid string) error
// Pricelists (read-only in offline mode)
GetPricelists() ([]models.PricelistSummary, error)
GetPricelistByID(id uint) (*models.Pricelist, error)
GetPricelistItems(pricelistID uint) ([]models.PricelistItem, error)
GetLatestPricelist() (*models.Pricelist, error)
}
// UnifiedRepo implements DataSource with automatic online/offline switching
type UnifiedRepo struct {
mariaDB *gorm.DB
localDB *localdb.LocalDB
isOnline bool
}
// NewUnifiedRepo creates a new unified repository
func NewUnifiedRepo(mariaDB *gorm.DB, localDB *localdb.LocalDB, isOnline bool) *UnifiedRepo {
return &UnifiedRepo{
mariaDB: mariaDB,
localDB: localDB,
isOnline: isOnline,
}
}
// SetOnlineStatus updates the online/offline status
func (r *UnifiedRepo) SetOnlineStatus(online bool) {
r.isOnline = online
}
// IsOnline returns the current online/offline status
func (r *UnifiedRepo) IsOnline() bool {
return r.isOnline
}
// Component methods
// GetComponents returns components from MariaDB (online) or local cache (offline)
func (r *UnifiedRepo) GetComponents(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) {
if r.isOnline {
return r.getComponentsOnline(filter, offset, limit)
}
return r.getComponentsOffline(filter, offset, limit)
}
func (r *UnifiedRepo) getComponentsOnline(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) {
repo := NewComponentRepository(r.mariaDB)
return repo.List(filter, offset, limit)
}
func (r *UnifiedRepo) getComponentsOffline(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) {
var components []localdb.LocalComponent
query := r.localDB.DB().Model(&localdb.LocalComponent{})
// Apply filters
if filter.Category != "" {
query = query.Where("category = ?", filter.Category)
}
if filter.Search != "" {
search := "%" + filter.Search + "%"
query = query.Where("lot_name LIKE ? OR lot_description LIKE ? OR model LIKE ?", search, search, search)
}
var total int64
query.Count(&total)
// Apply sorting
sortDir := "ASC"
if filter.SortDir == "desc" {
sortDir = "DESC"
}
switch filter.SortField {
case "lot_name":
query = query.Order("lot_name " + sortDir)
default:
query = query.Order("lot_name ASC")
}
if err := query.Offset(offset).Limit(limit).Find(&components).Error; err != nil {
return nil, 0, fmt.Errorf("fetching offline components: %w", err)
}
// Convert to models.LotMetadata
result := make([]models.LotMetadata, len(components))
for i, comp := range components {
result[i] = models.LotMetadata{
LotName: comp.LotName,
Model: comp.Model,
Lot: &models.Lot{
LotName: comp.LotName,
LotDescription: comp.LotDescription,
},
}
}
return result, total, nil
}
// GetComponent returns a single component by lot name
func (r *UnifiedRepo) GetComponent(lotName string) (*models.LotMetadata, error) {
if r.isOnline {
repo := NewComponentRepository(r.mariaDB)
return repo.GetByLotName(lotName)
}
var comp localdb.LocalComponent
if err := r.localDB.DB().Where("lot_name = ?", lotName).First(&comp).Error; err != nil {
return nil, fmt.Errorf("fetching offline component: %w", err)
}
return &models.LotMetadata{
LotName: comp.LotName,
Model: comp.Model,
Lot: &models.Lot{
LotName: comp.LotName,
LotDescription: comp.LotDescription,
},
}, nil
}
// Configuration methods
// SaveConfiguration saves a configuration (online: MariaDB, offline: SQLite + pending_changes)
func (r *UnifiedRepo) SaveConfiguration(cfg *models.Configuration) error {
if r.isOnline {
repo := NewConfigurationRepository(r.mariaDB)
return repo.Create(cfg)
}
// Offline: save to local SQLite and queue for sync
localCfg := &localdb.LocalConfiguration{
UUID: cfg.UUID,
Name: cfg.Name,
TotalPrice: cfg.TotalPrice,
CustomPrice: cfg.CustomPrice,
Notes: cfg.Notes,
IsTemplate: cfg.IsTemplate,
ServerCount: cfg.ServerCount,
CreatedAt: cfg.CreatedAt,
UpdatedAt: time.Now(),
SyncStatus: "pending",
OriginalUsername: cfg.OwnerUsername,
}
// Convert items
localItems := make(localdb.LocalConfigItems, len(cfg.Items))
for i, item := range cfg.Items {
localItems[i] = localdb.LocalConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: item.UnitPrice,
}
}
localCfg.Items = localItems
if err := r.localDB.SaveConfiguration(localCfg); err != nil {
return fmt.Errorf("saving local configuration: %w", err)
}
// Add to pending changes queue
payload, err := json.Marshal(cfg)
if err != nil {
return fmt.Errorf("marshaling configuration for sync: %w", err)
}
return r.localDB.AddPendingChange("configuration", cfg.UUID, "create", string(payload))
}
// GetConfigurations returns all configurations for a user
func (r *UnifiedRepo) GetConfigurations(ownerUsername string) ([]models.Configuration, error) {
if r.isOnline {
repo := NewConfigurationRepository(r.mariaDB)
configs, _, err := repo.ListByUser(ownerUsername, 0, 1000)
return configs, err
}
// Offline: get from local SQLite
localConfigs, err := r.localDB.GetConfigurations()
if err != nil {
return nil, fmt.Errorf("fetching local configurations: %w", err)
}
// Convert to models.Configuration
result := make([]models.Configuration, len(localConfigs))
for i, lc := range localConfigs {
items := make(models.ConfigItems, len(lc.Items))
for j, item := range lc.Items {
items[j] = models.ConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: item.UnitPrice,
}
}
result[i] = models.Configuration{
UUID: lc.UUID,
OwnerUsername: lc.OriginalUsername,
Name: lc.Name,
Items: items,
TotalPrice: lc.TotalPrice,
CustomPrice: lc.CustomPrice,
Notes: lc.Notes,
IsTemplate: lc.IsTemplate,
ServerCount: lc.ServerCount,
CreatedAt: lc.CreatedAt,
}
}
return result, nil
}
// GetConfigurationByUUID returns a configuration by UUID
func (r *UnifiedRepo) GetConfigurationByUUID(uuid string) (*models.Configuration, error) {
if r.isOnline {
repo := NewConfigurationRepository(r.mariaDB)
return repo.GetByUUID(uuid)
}
localCfg, err := r.localDB.GetConfigurationByUUID(uuid)
if err != nil {
return nil, fmt.Errorf("fetching local configuration: %w", err)
}
items := make(models.ConfigItems, len(localCfg.Items))
for i, item := range localCfg.Items {
items[i] = models.ConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: item.UnitPrice,
}
}
return &models.Configuration{
UUID: localCfg.UUID,
Name: localCfg.Name,
Items: items,
TotalPrice: localCfg.TotalPrice,
CustomPrice: localCfg.CustomPrice,
Notes: localCfg.Notes,
IsTemplate: localCfg.IsTemplate,
ServerCount: localCfg.ServerCount,
CreatedAt: localCfg.CreatedAt,
}, nil
}
// DeleteConfiguration deletes a configuration
func (r *UnifiedRepo) DeleteConfiguration(uuid string) error {
if r.isOnline {
// Get ID first
cfg, err := r.GetConfigurationByUUID(uuid)
if err != nil {
return err
}
repo := NewConfigurationRepository(r.mariaDB)
return repo.Delete(cfg.ID)
}
// Offline: delete from local and queue sync
if err := r.localDB.DeleteConfiguration(uuid); err != nil {
return fmt.Errorf("deleting local configuration: %w", err)
}
return r.localDB.AddPendingChange("configuration", uuid, "delete", "")
}
// Pricelist methods
// GetPricelists returns all pricelists
func (r *UnifiedRepo) GetPricelists() ([]models.PricelistSummary, error) {
if r.isOnline {
repo := NewPricelistRepository(r.mariaDB)
summaries, _, err := repo.List(0, 1000)
return summaries, err
}
// Offline: get from local cache
localPLs, err := r.localDB.GetLocalPricelists()
if err != nil {
return nil, fmt.Errorf("fetching local pricelists: %w", err)
}
summaries := make([]models.PricelistSummary, len(localPLs))
for i, pl := range localPLs {
itemCount := r.localDB.CountLocalPricelistItems(pl.ID)
summaries[i] = models.PricelistSummary{
ID: pl.ServerID,
Version: pl.Version,
CreatedAt: pl.CreatedAt,
ItemCount: itemCount,
}
}
return summaries, nil
}
// GetPricelistByID returns a pricelist by ID
func (r *UnifiedRepo) GetPricelistByID(id uint) (*models.Pricelist, error) {
if r.isOnline {
repo := NewPricelistRepository(r.mariaDB)
return repo.GetByID(id)
}
// Offline: get from local cache
localPL, err := r.localDB.GetLocalPricelistByServerID(id)
if err != nil {
return nil, fmt.Errorf("fetching local pricelist: %w", err)
}
itemCount := r.localDB.CountLocalPricelistItems(localPL.ID)
return &models.Pricelist{
ID: localPL.ServerID,
Version: localPL.Version,
CreatedAt: localPL.CreatedAt,
ItemCount: int(itemCount),
}, nil
}
// GetPricelistItems returns items for a pricelist
func (r *UnifiedRepo) GetPricelistItems(pricelistID uint) ([]models.PricelistItem, error) {
if r.isOnline {
repo := NewPricelistRepository(r.mariaDB)
items, _, err := repo.GetItems(pricelistID, 0, 100000, "")
return items, err
}
// Offline: get from local cache
// First find the local pricelist by server ID
localPL, err := r.localDB.GetLocalPricelistByServerID(pricelistID)
if err != nil {
return nil, fmt.Errorf("fetching local pricelist: %w", err)
}
localItems, err := r.localDB.GetLocalPricelistItems(localPL.ID)
if err != nil {
return nil, fmt.Errorf("fetching local pricelist items: %w", err)
}
items := make([]models.PricelistItem, len(localItems))
for i, item := range localItems {
items[i] = models.PricelistItem{
ID: item.ID,
PricelistID: pricelistID,
LotName: item.LotName,
Price: item.Price,
}
}
return items, nil
}
// GetLatestPricelist returns the latest pricelist
func (r *UnifiedRepo) GetLatestPricelist() (*models.Pricelist, error) {
if r.isOnline {
repo := NewPricelistRepository(r.mariaDB)
return repo.GetLatestActive()
}
// Offline: get from local cache
localPL, err := r.localDB.GetLatestLocalPricelist()
if err != nil {
return nil, fmt.Errorf("fetching latest local pricelist: %w", err)
}
itemCount := r.localDB.CountLocalPricelistItems(localPL.ID)
return &models.Pricelist{
ID: localPL.ServerID,
Version: localPL.Version,
CreatedAt: localPL.CreatedAt,
ItemCount: int(itemCount),
}, nil
}

View File

@@ -1,43 +1,5 @@
package services package services
import (
"fmt"
"log/slog"
"strings"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
)
type ComponentService struct {
componentRepo *repository.ComponentRepository
categoryRepo *repository.CategoryRepository
}
func NewComponentService(
componentRepo *repository.ComponentRepository,
categoryRepo *repository.CategoryRepository,
) *ComponentService {
return &ComponentService{
componentRepo: componentRepo,
categoryRepo: categoryRepo,
}
}
// ParsePartNumber extracts category and model from lot_name
// "CPU_AMD_9654" → category="CPU", model="AMD_9654"
// "MB_INTEL_4.Sapphire_2S_32xDDR5" → category="MB", model="INTEL_4.Sapphire_2S_32xDDR5"
func ParsePartNumber(lotName string) (category, model string) {
parts := strings.SplitN(lotName, "_", 2)
if len(parts) >= 1 {
category = parts[0]
}
if len(parts) >= 2 {
model = parts[1]
}
return
}
type ComponentListResult struct { type ComponentListResult struct {
Items []ComponentView `json:"items"` Items []ComponentView `json:"items"`
TotalCount int64 `json:"total_count"` TotalCount int64 `json:"total_count"`
@@ -47,178 +9,9 @@ type ComponentListResult struct {
} }
type ComponentView struct { type ComponentView struct {
LotName string `json:"lot_name"` LotName string `json:"lot_name"`
Description string `json:"description"` Description string `json:"description"`
Category string `json:"category"` Category string `json:"category"`
CategoryName string `json:"category_name"` CategoryName string `json:"category_name"`
Model string `json:"model"` Model string `json:"model"`
PriceFreshness models.PriceFreshness `json:"price_freshness"`
PopularityScore float64 `json:"popularity_score"`
Specs models.Specs `json:"specs,omitempty"`
}
func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage int) (*ComponentListResult, error) {
// If no database connection (offline mode), return empty list
// Components should be loaded via /api/sync/components first
if s.componentRepo == nil {
return &ComponentListResult{
Items: []ComponentView{},
TotalCount: 0,
Page: page,
PerPage: perPage,
TotalPages: 1,
}, nil
}
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 20
}
if perPage > 5000 {
perPage = 5000
}
offset := (page - 1) * perPage
components, total, err := s.componentRepo.List(filter, offset, perPage)
if err != nil {
return nil, err
}
views := make([]ComponentView, len(components))
for i, c := range components {
view := ComponentView{
LotName: c.LotName,
Model: c.Model,
PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3),
PopularityScore: c.PopularityScore,
Specs: c.Specs,
}
if c.Lot != nil {
view.Description = c.Lot.LotDescription
}
if c.Category != nil {
view.Category = c.Category.Code
view.CategoryName = c.Category.Name
}
views[i] = view
}
totalPages := int((total + int64(perPage) - 1) / int64(perPage))
if totalPages < 1 {
totalPages = 1
}
return &ComponentListResult{
Items: views,
TotalCount: total,
Page: page,
PerPage: perPage,
TotalPages: totalPages,
}, nil
}
func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error) {
// If no database connection (offline mode), return error
if s.componentRepo == nil {
return nil, fmt.Errorf("offline mode: component data not available")
}
c, err := s.componentRepo.GetByLotName(lotName)
if err != nil {
return nil, err
}
// Track usage (best-effort)
if err := s.componentRepo.IncrementRequestCount(lotName); err != nil {
slog.Warn("component: could not increment request count", "lot", lotName, "err", err)
}
view := &ComponentView{
LotName: c.LotName,
Model: c.Model,
PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3),
PopularityScore: c.PopularityScore,
Specs: c.Specs,
}
if c.Lot != nil {
view.Description = c.Lot.LotDescription
}
if c.Category != nil {
view.Category = c.Category.Code
view.CategoryName = c.Category.Name
}
return view, nil
}
func (s *ComponentService) GetCategories() ([]models.Category, error) {
// If no database connection (offline mode), return default categories
if s.categoryRepo == nil {
return models.DefaultCategories, nil
}
return s.categoryRepo.GetAll()
}
// ImportFromLot creates metadata entries for lots that don't have them
func (s *ComponentService) ImportFromLot() (int, error) {
// If no database connection (offline mode), return error
if s.componentRepo == nil || s.categoryRepo == nil {
return 0, fmt.Errorf("offline mode: import not available")
}
lots, err := s.componentRepo.GetLotsWithoutMetadata()
if err != nil {
return 0, err
}
categories, err := s.categoryRepo.GetAll()
if err != nil {
return 0, err
}
categoryMap := make(map[string]uint)
for _, cat := range categories {
categoryMap[strings.ToUpper(cat.Code)] = cat.ID
}
imported := 0
for _, lot := range lots {
// Use lot_category from database if available, otherwise parse from lot_name
var category string
if lot.LotCategory != nil && *lot.LotCategory != "" {
category = strings.ToUpper(*lot.LotCategory)
} else {
category, _ = ParsePartNumber(lot.LotName)
category = strings.ToUpper(category)
}
_, model := ParsePartNumber(lot.LotName)
metadata := &models.LotMetadata{
LotName: lot.LotName,
Model: model,
Specs: make(models.Specs),
}
if catID, ok := categoryMap[category]; ok {
metadata.CategoryID = &catID
} else {
// Create new category if it doesn't exist
newCat, err := s.categoryRepo.CreateIfNotExists(category)
if err == nil && newCat != nil {
metadata.CategoryID = &newCat.ID
}
}
if err := s.componentRepo.Create(metadata); err != nil {
continue
}
imported++
}
return imported, nil
} }

View File

@@ -2,11 +2,8 @@ package services
import ( import (
"errors" "errors"
"time"
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"github.com/google/uuid"
) )
var ( var (
@@ -14,37 +11,13 @@ var (
ErrConfigForbidden = errors.New("access to configuration forbidden") ErrConfigForbidden = errors.New("access to configuration forbidden")
) )
// ConfigurationGetter is an interface for services that can retrieve configurations // ConfigurationGetter is an interface for services that can retrieve configurations.
// Used by handlers to work with both ConfigurationService and LocalConfigurationService // Used by handlers to work with LocalConfigurationService.
type ConfigurationGetter interface { type ConfigurationGetter interface {
GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error) GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error)
GetByUUIDNoAuth(uuid string) (*models.Configuration, error) GetByUUIDNoAuth(uuid string) (*models.Configuration, error)
} }
type ConfigurationService struct {
configRepo *repository.ConfigurationRepository
projectRepo *repository.ProjectRepository
componentRepo *repository.ComponentRepository
pricelistRepo *repository.PricelistRepository
quoteService *QuoteService
}
func NewConfigurationService(
configRepo *repository.ConfigurationRepository,
projectRepo *repository.ProjectRepository,
componentRepo *repository.ComponentRepository,
pricelistRepo *repository.PricelistRepository,
quoteService *QuoteService,
) *ConfigurationService {
return &ConfigurationService{
configRepo: configRepo,
projectRepo: projectRepo,
componentRepo: componentRepo,
pricelistRepo: pricelistRepo,
quoteService: quoteService,
}
}
type CreateConfigRequest struct { type CreateConfigRequest struct {
Name string `json:"name"` Name string `json:"name"`
Items models.ConfigItems `json:"items"` Items models.ConfigItems `json:"items"`
@@ -70,583 +43,3 @@ type ArticlePreviewRequest struct {
SupportCode string `json:"support_code,omitempty"` SupportCode string `json:"support_code,omitempty"`
PricelistID *uint `json:"pricelist_id,omitempty"` PricelistID *uint `json:"pricelist_id,omitempty"`
} }
func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
if err != nil {
return nil, err
}
pricelistID, err := s.resolvePricelistID(req.PricelistID)
if err != nil {
return nil, err
}
total := req.Items.Total()
// If server count is greater than 1, multiply the total by server count
if req.ServerCount > 1 {
total *= float64(req.ServerCount)
}
config := &models.Configuration{
UUID: uuid.New().String(),
OwnerUsername: ownerUsername,
ProjectUUID: projectUUID,
Name: req.Name,
Items: req.Items,
TotalPrice: &total,
CustomPrice: req.CustomPrice,
Notes: req.Notes,
IsTemplate: req.IsTemplate,
ServerCount: req.ServerCount,
ServerModel: req.ServerModel,
SupportCode: req.SupportCode,
Article: req.Article,
PricelistID: pricelistID,
WarehousePricelistID: req.WarehousePricelistID,
CompetitorPricelistID: req.CompetitorPricelistID,
ConfigType: req.ConfigType,
DisablePriceRefresh: req.DisablePriceRefresh,
OnlyInStock: req.OnlyInStock,
}
if config.ConfigType == "" {
config.ConfigType = "server"
}
if err := s.configRepo.Create(config); err != nil {
return nil, err
}
return config, nil
}
func (s *ConfigurationService) GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error) {
config, err := s.configRepo.GetByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
// Allow access if user owns config or it's a template
if !s.isOwner(config, ownerUsername) && !config.IsTemplate {
return nil, ErrConfigForbidden
}
return config, nil
}
func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
config, err := s.configRepo.GetByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
if !s.isOwner(config, ownerUsername) {
return nil, ErrConfigForbidden
}
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
if err != nil {
return nil, err
}
pricelistID, err := s.resolvePricelistID(req.PricelistID)
if err != nil {
return nil, err
}
total := req.Items.Total()
// If server count is greater than 1, multiply the total by server count
if req.ServerCount > 1 {
total *= float64(req.ServerCount)
}
config.Name = req.Name
config.ProjectUUID = projectUUID
config.Items = req.Items
config.TotalPrice = &total
config.CustomPrice = req.CustomPrice
config.Notes = req.Notes
config.IsTemplate = req.IsTemplate
config.ServerCount = req.ServerCount
config.ServerModel = req.ServerModel
config.SupportCode = req.SupportCode
config.Article = req.Article
config.PricelistID = pricelistID
config.WarehousePricelistID = req.WarehousePricelistID
config.CompetitorPricelistID = req.CompetitorPricelistID
config.DisablePriceRefresh = req.DisablePriceRefresh
config.OnlyInStock = req.OnlyInStock
if err := s.configRepo.Update(config); err != nil {
return nil, err
}
return config, nil
}
func (s *ConfigurationService) Delete(uuid string, ownerUsername string) error {
config, err := s.configRepo.GetByUUID(uuid)
if err != nil {
return ErrConfigNotFound
}
if !s.isOwner(config, ownerUsername) {
return ErrConfigForbidden
}
return s.configRepo.Delete(config.ID)
}
func (s *ConfigurationService) Rename(uuid string, ownerUsername string, newName string) (*models.Configuration, error) {
config, err := s.configRepo.GetByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
if !s.isOwner(config, ownerUsername) {
return nil, ErrConfigForbidden
}
config.Name = newName
if err := s.configRepo.Update(config); err != nil {
return nil, err
}
return config, nil
}
func (s *ConfigurationService) Clone(configUUID string, ownerUsername string, newName string) (*models.Configuration, error) {
return s.CloneToProject(configUUID, ownerUsername, newName, nil)
}
func (s *ConfigurationService) CloneToProject(configUUID string, ownerUsername string, newName string, projectUUID *string) (*models.Configuration, error) {
original, err := s.GetByUUID(configUUID, ownerUsername)
if err != nil {
return nil, err
}
resolvedProjectUUID := original.ProjectUUID
if projectUUID != nil {
resolvedProjectUUID, err = s.resolveProjectUUID(ownerUsername, projectUUID)
if err != nil {
return nil, err
}
}
// Create copy with new UUID and name
total := original.Items.Total()
// If server count is greater than 1, multiply the total by server count
if original.ServerCount > 1 {
total *= float64(original.ServerCount)
}
clone := &models.Configuration{
UUID: uuid.New().String(),
OwnerUsername: ownerUsername,
ProjectUUID: resolvedProjectUUID,
Name: newName,
Items: original.Items,
TotalPrice: &total,
CustomPrice: original.CustomPrice,
Notes: original.Notes,
IsTemplate: false, // Clone is never a template
ServerCount: original.ServerCount,
ServerModel: original.ServerModel,
SupportCode: original.SupportCode,
Article: original.Article,
PricelistID: original.PricelistID,
WarehousePricelistID: original.WarehousePricelistID,
CompetitorPricelistID: original.CompetitorPricelistID,
DisablePriceRefresh: original.DisablePriceRefresh,
OnlyInStock: original.OnlyInStock,
}
if err := s.configRepo.Create(clone); err != nil {
return nil, err
}
return clone, nil
}
func (s *ConfigurationService) ListByUser(ownerUsername string, page, perPage int) ([]models.Configuration, int64, error) {
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 100 {
perPage = 20
}
offset := (page - 1) * perPage
return s.configRepo.ListByUser(ownerUsername, offset, perPage)
}
// ListAll returns all configurations without user filter (for use when auth is disabled)
func (s *ConfigurationService) ListAll(page, perPage int) ([]models.Configuration, int64, error) {
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 100 {
perPage = 20
}
offset := (page - 1) * perPage
return s.configRepo.ListAll(offset, perPage)
}
// GetByUUIDNoAuth returns configuration without ownership check (for use when auth is disabled)
func (s *ConfigurationService) GetByUUIDNoAuth(uuid string) (*models.Configuration, error) {
config, err := s.configRepo.GetByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
return config, nil
}
// UpdateNoAuth updates configuration without ownership check
func (s *ConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigRequest) (*models.Configuration, error) {
config, err := s.configRepo.GetByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
projectUUID, err := s.resolveProjectUUID(config.OwnerUsername, req.ProjectUUID)
if err != nil {
return nil, err
}
pricelistID, err := s.resolvePricelistID(req.PricelistID)
if err != nil {
return nil, err
}
total := req.Items.Total()
if req.ServerCount > 1 {
total *= float64(req.ServerCount)
}
config.Name = req.Name
config.ProjectUUID = projectUUID
config.Items = req.Items
config.TotalPrice = &total
config.CustomPrice = req.CustomPrice
config.Notes = req.Notes
config.IsTemplate = req.IsTemplate
config.ServerCount = req.ServerCount
config.ServerModel = req.ServerModel
config.SupportCode = req.SupportCode
config.Article = req.Article
config.PricelistID = pricelistID
config.WarehousePricelistID = req.WarehousePricelistID
config.CompetitorPricelistID = req.CompetitorPricelistID
config.DisablePriceRefresh = req.DisablePriceRefresh
config.OnlyInStock = req.OnlyInStock
if err := s.configRepo.Update(config); err != nil {
return nil, err
}
return config, nil
}
// DeleteNoAuth deletes configuration without ownership check
func (s *ConfigurationService) DeleteNoAuth(uuid string) error {
config, err := s.configRepo.GetByUUID(uuid)
if err != nil {
return ErrConfigNotFound
}
return s.configRepo.Delete(config.ID)
}
// RenameNoAuth renames configuration without ownership check
func (s *ConfigurationService) RenameNoAuth(uuid string, newName string) (*models.Configuration, error) {
config, err := s.configRepo.GetByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
config.Name = newName
if err := s.configRepo.Update(config); err != nil {
return nil, err
}
return config, nil
}
// CloneNoAuth clones configuration without ownership check
func (s *ConfigurationService) CloneNoAuth(configUUID string, newName string, ownerUsername string) (*models.Configuration, error) {
return s.CloneNoAuthToProject(configUUID, newName, ownerUsername, nil)
}
func (s *ConfigurationService) CloneNoAuthToProject(configUUID string, newName string, ownerUsername string, projectUUID *string) (*models.Configuration, error) {
original, err := s.configRepo.GetByUUID(configUUID)
if err != nil {
return nil, ErrConfigNotFound
}
resolvedProjectUUID := original.ProjectUUID
if projectUUID != nil {
resolvedProjectUUID, err = s.resolveProjectUUID(ownerUsername, projectUUID)
if err != nil {
return nil, err
}
}
total := original.Items.Total()
if original.ServerCount > 1 {
total *= float64(original.ServerCount)
}
clone := &models.Configuration{
UUID: uuid.New().String(),
OwnerUsername: ownerUsername,
ProjectUUID: resolvedProjectUUID,
Name: newName,
Items: original.Items,
TotalPrice: &total,
CustomPrice: original.CustomPrice,
Notes: original.Notes,
IsTemplate: false,
ServerCount: original.ServerCount,
PricelistID: original.PricelistID,
OnlyInStock: original.OnlyInStock,
}
if err := s.configRepo.Create(clone); err != nil {
return nil, err
}
return clone, nil
}
func (s *ConfigurationService) resolveProjectUUID(ownerUsername string, projectUUID *string) (*string, error) {
_ = ownerUsername
if s.projectRepo == nil {
return projectUUID, nil
}
if projectUUID == nil || *projectUUID == "" {
return nil, nil
}
project, err := s.projectRepo.GetByUUID(*projectUUID)
if err != nil {
return nil, ErrProjectNotFound
}
if !project.IsActive {
return nil, errors.New("project is archived")
}
return &project.UUID, nil
}
func (s *ConfigurationService) resolvePricelistID(pricelistID *uint) (*uint, error) {
if s.pricelistRepo == nil {
return pricelistID, nil
}
if pricelistID != nil && *pricelistID > 0 {
if _, err := s.pricelistRepo.GetByID(*pricelistID); err != nil {
return nil, err
}
return pricelistID, nil
}
latest, err := s.pricelistRepo.GetLatestActive()
if err != nil {
return nil, nil
}
return &latest.ID, nil
}
// RefreshPricesNoAuth refreshes prices without ownership check
func (s *ConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) {
config, err := s.configRepo.GetByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
var latestPricelistID *uint
if s.pricelistRepo != nil {
if pl, err := s.pricelistRepo.GetLatestActive(); err == nil {
latestPricelistID = &pl.ID
}
}
updatedItems := make(models.ConfigItems, len(config.Items))
for i, item := range config.Items {
if latestPricelistID != nil {
if price, err := s.pricelistRepo.GetPriceForLot(*latestPricelistID, item.LotName); err == nil && price > 0 {
updatedItems[i] = models.ConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: price,
}
continue
}
}
if s.componentRepo == nil {
updatedItems[i] = item
continue
}
metadata, err := s.componentRepo.GetByLotName(item.LotName)
if err != nil || metadata.CurrentPrice == nil {
updatedItems[i] = item
continue
}
updatedItems[i] = models.ConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: *metadata.CurrentPrice,
}
}
config.Items = updatedItems
total := updatedItems.Total()
if config.ServerCount > 1 {
total *= float64(config.ServerCount)
}
config.TotalPrice = &total
if latestPricelistID != nil {
config.PricelistID = latestPricelistID
}
now := time.Now()
config.PriceUpdatedAt = &now
if err := s.configRepo.Update(config); err != nil {
return nil, err
}
return config, nil
}
func (s *ConfigurationService) ListTemplates(page, perPage int) ([]models.Configuration, int64, error) {
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 100 {
perPage = 20
}
offset := (page - 1) * perPage
return s.configRepo.ListTemplates(offset, perPage)
}
// RefreshPrices updates all component prices in the configuration with current prices
func (s *ConfigurationService) RefreshPrices(uuid string, ownerUsername string) (*models.Configuration, error) {
config, err := s.configRepo.GetByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
if !s.isOwner(config, ownerUsername) {
return nil, ErrConfigForbidden
}
var latestPricelistID *uint
if s.pricelistRepo != nil {
if pl, err := s.pricelistRepo.GetLatestActive(); err == nil {
latestPricelistID = &pl.ID
}
}
// Update prices for all items
updatedItems := make(models.ConfigItems, len(config.Items))
for i, item := range config.Items {
if latestPricelistID != nil {
if price, err := s.pricelistRepo.GetPriceForLot(*latestPricelistID, item.LotName); err == nil && price > 0 {
updatedItems[i] = models.ConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: price,
}
continue
}
}
// Get current component price
if s.componentRepo == nil {
updatedItems[i] = item
continue
}
metadata, err := s.componentRepo.GetByLotName(item.LotName)
if err != nil || metadata.CurrentPrice == nil {
// Keep original item if component not found or no price available
updatedItems[i] = item
continue
}
// Update item with current price
updatedItems[i] = models.ConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: *metadata.CurrentPrice,
}
}
// Update configuration
config.Items = updatedItems
total := updatedItems.Total()
// If server count is greater than 1, multiply the total by server count
if config.ServerCount > 1 {
total *= float64(config.ServerCount)
}
config.TotalPrice = &total
if latestPricelistID != nil {
config.PricelistID = latestPricelistID
}
// Set price update timestamp
now := time.Now()
config.PriceUpdatedAt = &now
if err := s.configRepo.Update(config); err != nil {
return nil, err
}
return config, nil
}
func (s *ConfigurationService) isOwner(config *models.Configuration, ownerUsername string) bool {
if config == nil || ownerUsername == "" {
return false
}
return config.OwnerUsername == ownerUsername
}
// // Export configuration as JSON
// type ConfigExport struct {
// Name string `json:"name"`
// Notes string `json:"notes"`
// Items models.ConfigItems `json:"items"`
// }
//
// func (s *ConfigurationService) ExportJSON(uuid string, userID uint) ([]byte, error) {
// config, err := s.GetByUUID(uuid, userID)
// if err != nil {
// return nil, err
// }
//
// export := ConfigExport{
// Name: config.Name,
// Notes: config.Notes,
// Items: config.Items,
// }
//
// return json.MarshalIndent(export, "", " ")
// }
//
// func (s *ConfigurationService) ImportJSON(userID uint, data []byte) (*models.Configuration, error) {
// var export ConfigExport
// if err := json.Unmarshal(data, &export); err != nil {
// return nil, err
// }
//
// req := &CreateConfigRequest{
// Name: export.Name,
// Notes: export.Notes,
// Items: export.Items,
// }
//
// return s.Create(userID, req)
// }

View File

@@ -380,7 +380,7 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
if opts.IncludeBOM && localCfg != nil && len(localCfg.VendorSpec) > 0 { if opts.IncludeBOM && localCfg != nil && len(localCfg.VendorSpec) > 0 {
coveredLots := make(map[string]struct{}) coveredLots := make(map[string]struct{})
for _, row := range localCfg.VendorSpec { for _, row := range localCfg.VendorSpec {
rowMappings := normalizeLotMappings(row.LotMappings) rowMappings := localdb.NormalizeLotMappings(row.LotMappings)
for _, mapping := range rowMappings { for _, mapping := range rowMappings {
coveredLots[mapping.LotName] = struct{}{} coveredLots[mapping.LotName] = struct{}{}
} }
@@ -424,21 +424,22 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
} }
for _, item := range cfg.Items { for _, item := range cfg.Items {
if item.LotName == "" { lot := models.NormalizeLotName(item.LotName)
if lot == "" {
continue continue
} }
if _, ok := coveredLots[item.LotName]; ok { if _, ok := coveredLots[lot]; ok {
continue continue
} }
estimate := estimateOnlyTotal(priceMap[item.LotName].Estimate, item.UnitPrice, item.Quantity) estimate := estimateOnlyTotal(priceMap[lot].Estimate, item.UnitPrice, item.Quantity)
block.Rows = append(block.Rows, ProjectPricingExportRow{ block.Rows = append(block.Rows, ProjectPricingExportRow{
LotDisplay: item.LotName, LotDisplay: lot,
VendorPN: "—", VendorPN: "—",
Description: componentDescriptions[item.LotName], Description: componentDescriptions[lot],
Quantity: exportPositiveInt(item.Quantity, 1), Quantity: exportPositiveInt(item.Quantity, 1),
Estimate: estimate, Estimate: estimate,
Stock: totalForUnitPrice(priceMap[item.LotName].Stock, item.Quantity), Stock: totalForUnitPrice(priceMap[lot].Stock, item.Quantity),
Competitor: totalForUnitPrice(priceMap[item.LotName].Competitor, item.Quantity), Competitor: totalForUnitPrice(priceMap[lot].Competitor, item.Quantity),
}) })
} }
if opts.isDDP() { if opts.isDDP() {
@@ -665,7 +666,7 @@ func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfig
out := make([]string, 0) out := make([]string, 0)
if includeBOM && localCfg != nil { if includeBOM && localCfg != nil {
for _, row := range localCfg.VendorSpec { for _, row := range localCfg.VendorSpec {
for _, mapping := range normalizeLotMappings(row.LotMappings) { for _, mapping := range localdb.NormalizeLotMappings(row.LotMappings) {
if _, ok := seen[mapping.LotName]; ok { if _, ok := seen[mapping.LotName]; ok {
continue continue
} }
@@ -688,28 +689,6 @@ func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfig
return out return out
} }
func normalizeLotMappings(mappings []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping {
if len(mappings) == 0 {
return nil
}
out := make([]localdb.VendorSpecLotMapping, 0, len(mappings))
for _, mapping := range mappings {
lot := strings.TrimSpace(mapping.LotName)
if lot == "" {
continue
}
qty := mapping.QuantityPerPN
if qty < 1 {
qty = 1
}
out = append(out, localdb.VendorSpecLotMapping{
LotName: lot,
QuantityPerPN: qty,
})
}
return out
}
func vendorRowTotal(row localdb.VendorSpecItem) *float64 { func vendorRowTotal(row localdb.VendorSpecItem) *float64 {
if row.TotalPrice != nil { if row.TotalPrice != nil {
return floatPtr(*row.TotalPrice) return floatPtr(*row.TotalPrice)
@@ -720,27 +699,6 @@ func vendorRowTotal(row localdb.VendorSpecItem) *float64 {
return floatPtr(*row.UnitPrice * float64(exportPositiveInt(row.Quantity, 1))) return floatPtr(*row.UnitPrice * float64(exportPositiveInt(row.Quantity, 1)))
} }
func computeMappingTotal(priceMap map[string]pricingLevels, mappings []localdb.VendorSpecLotMapping, pnQty int, selector func(pricingLevels) *float64) *float64 {
if len(mappings) == 0 {
return nil
}
total := 0.0
hasValue := false
qty := exportPositiveInt(pnQty, 1)
for _, mapping := range mappings {
price := selector(priceMap[mapping.LotName])
if price == nil || *price <= 0 {
continue
}
total += *price * float64(qty*mapping.QuantityPerPN)
hasValue = true
}
if !hasValue {
return nil
}
return floatPtr(total)
}
// distributeManualPrice sets ManualPrice on each row proportionally based on the // distributeManualPrice sets ManualPrice on each row proportionally based on the
// row's Estimate share. The last row with a price absorbs rounding remainder so // row's Estimate share. The last row with a price absorbs rounding remainder so
// the sum of ManualPrice values always equals manualPrice exactly. // the sum of ManualPrice values always equals manualPrice exactly.

View File

@@ -1813,7 +1813,8 @@ func (s *LocalConfigurationService) resolvePricelistID(pricelistID *uint) (*uint
} }
} }
} }
return nil, fmt.Errorf("pricelist %d not available locally", *pricelistID) // Pricelist not found even after sync — fall back to the latest active one.
slog.Warn("pricelist not available locally, falling back to latest active", "server_pricelist_id", *pricelistID)
} }
latest, err := s.localDB.GetLatestLocalPricelist() latest, err := s.localDB.GetLatestLocalPricelist()

View File

@@ -18,32 +18,22 @@ var (
) )
type QuoteService struct { type QuoteService struct {
componentRepo *repository.ComponentRepository pricelistRepo *repository.PricelistRepository
pricelistRepo *repository.PricelistRepository localDB *localdb.LocalDB
localDB *localdb.LocalDB cacheMu sync.RWMutex
pricingService priceResolver priceCache map[string]cachedLotPrice
cacheMu sync.RWMutex cacheTTL time.Duration
priceCache map[string]cachedLotPrice
cacheTTL time.Duration
}
type priceResolver interface {
GetEffectivePrice(lotName string) (*float64, error)
} }
func NewQuoteService( func NewQuoteService(
componentRepo *repository.ComponentRepository,
pricelistRepo *repository.PricelistRepository, pricelistRepo *repository.PricelistRepository,
localDB *localdb.LocalDB, localDB *localdb.LocalDB,
pricingService priceResolver,
) *QuoteService { ) *QuoteService {
return &QuoteService{ return &QuoteService{
componentRepo: componentRepo, pricelistRepo: pricelistRepo,
pricelistRepo: pricelistRepo, localDB: localDB,
localDB: localDB, priceCache: make(map[string]cachedLotPrice, 4096),
pricingService: pricingService, cacheTTL: 10 * time.Second,
priceCache: make(map[string]cachedLotPrice, 4096),
cacheTTL: 10 * time.Second,
} }
} }
@@ -175,73 +165,7 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
return result, nil return result, nil
} }
if s.componentRepo == nil || s.pricingService == nil { return nil, errors.New("quote calculation requires local database")
return nil, errors.New("quote calculation not available")
}
result := &QuoteValidationResult{
Valid: true,
Items: make([]QuoteItem, 0, len(req.Items)),
Errors: make([]string, 0),
Warnings: make([]string, 0),
}
lotNames := make([]string, len(req.Items))
quantities := make(map[string]int)
for i, item := range req.Items {
lotNames[i] = item.LotName
quantities[item.LotName] = item.Quantity
}
components, err := s.componentRepo.GetMultiple(lotNames)
if err != nil {
return nil, err
}
componentMap := make(map[string]*models.LotMetadata)
for i := range components {
componentMap[components[i].LotName] = &components[i]
}
var total float64
for _, reqItem := range req.Items {
comp, exists := componentMap[reqItem.LotName]
if !exists {
result.Valid = false
result.Errors = append(result.Errors, "Component not found: "+reqItem.LotName)
continue
}
item := QuoteItem{
LotName: reqItem.LotName,
Quantity: reqItem.Quantity,
HasPrice: false,
}
if comp.Lot != nil {
item.Description = comp.Lot.LotDescription
}
if comp.Category != nil {
item.Category = comp.Category.Code
}
// Get effective price (override or calculated)
price, err := s.pricingService.GetEffectivePrice(reqItem.LotName)
if err == nil && price != nil && *price > 0 {
item.UnitPrice = *price
item.TotalPrice = *price * float64(reqItem.Quantity)
item.HasPrice = true
total += item.TotalPrice
} else {
result.Warnings = append(result.Warnings, "No price available for: "+reqItem.LotName)
}
result.Items = append(result.Items, item)
}
result.Total = total
return result, nil
} }
func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLevelsResult, error) { func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLevelsResult, error) {

View File

@@ -13,7 +13,7 @@ import (
func TestCalculatePriceLevels_WithMissingLevel(t *testing.T) { func TestCalculatePriceLevels_WithMissingLevel(t *testing.T) {
db := newPriceLevelsTestDB(t) db := newPriceLevelsTestDB(t)
repo := repository.NewPricelistRepository(db) repo := repository.NewPricelistRepository(db)
service := NewQuoteService(nil, repo, nil, nil) service := NewQuoteService(repo, nil)
estimate := seedPricelistWithItem(t, repo, "estimate", "CPU_X", 100) estimate := seedPricelistWithItem(t, repo, "estimate", "CPU_X", 100)
_ = estimate _ = estimate
@@ -57,7 +57,7 @@ func TestCalculatePriceLevels_WithMissingLevel(t *testing.T) {
func TestCalculatePriceLevels_UsesExplicitPricelistIDs(t *testing.T) { func TestCalculatePriceLevels_UsesExplicitPricelistIDs(t *testing.T) {
db := newPriceLevelsTestDB(t) db := newPriceLevelsTestDB(t)
repo := repository.NewPricelistRepository(db) repo := repository.NewPricelistRepository(db)
service := NewQuoteService(nil, repo, nil, nil) service := NewQuoteService(repo, nil)
olderEstimate := seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 80) olderEstimate := seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 80)
seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 90) seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 90)

View File

@@ -249,6 +249,13 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
pricelistItemsCount := s.localDB.CountAllPricelistItems() pricelistItemsCount := s.localDB.CountAllPricelistItems()
componentsCount := s.localDB.CountComponents() componentsCount := s.localDB.CountComponents()
dbSizeBytes := s.localDB.DBFileSizeBytes() dbSizeBytes := s.localDB.DBFileSizeBytes()
openConfigUUIDs := s.localDB.GetOpenConfigUUIDs()
var openConfigUUIDsJSON *string
if len(openConfigUUIDs) > 0 {
raw, _ := json.Marshal(openConfigUUIDs)
s := string(raw)
openConfigUUIDsJSON = &s
}
return mariaDB.Exec(` return mariaDB.Exec(`
INSERT INTO qt_client_schema_state ( INSERT INTO qt_client_schema_state (
username, hostname, app_version, username, hostname, app_version,
@@ -257,9 +264,10 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
estimate_pricelist_version, warehouse_pricelist_version, competitor_pricelist_version, estimate_pricelist_version, warehouse_pricelist_version, competitor_pricelist_version,
last_sync_error_code, last_sync_error_text, last_sync_error_code, last_sync_error_text,
local_pricelist_count, pricelist_items_count, components_count, db_size_bytes, local_pricelist_count, pricelist_items_count, components_count, db_size_bytes,
open_config_uuids,
last_checked_at, updated_at last_checked_at, updated_at
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
app_version = VALUES(app_version), app_version = VALUES(app_version),
last_sync_at = VALUES(last_sync_at), last_sync_at = VALUES(last_sync_at),
@@ -277,6 +285,7 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
pricelist_items_count = VALUES(pricelist_items_count), pricelist_items_count = VALUES(pricelist_items_count),
components_count = VALUES(components_count), components_count = VALUES(components_count),
db_size_bytes = VALUES(db_size_bytes), db_size_bytes = VALUES(db_size_bytes),
open_config_uuids = VALUES(open_config_uuids),
last_checked_at = VALUES(last_checked_at), last_checked_at = VALUES(last_checked_at),
updated_at = VALUES(updated_at) updated_at = VALUES(updated_at)
`, username, hostname, appmeta.Version(), `, username, hostname, appmeta.Version(),
@@ -285,6 +294,7 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
estimateVersion, warehouseVersion, competitorVersion, estimateVersion, warehouseVersion, competitorVersion,
lastSyncErrorCode, lastSyncErrorText, lastSyncErrorCode, lastSyncErrorText,
localPricelistCount, pricelistItemsCount, componentsCount, dbSizeBytes, localPricelistCount, pricelistItemsCount, componentsCount, dbSizeBytes,
openConfigUUIDsJSON,
checkedAt, checkedAt).Error checkedAt, checkedAt).Error
} }

View File

@@ -630,6 +630,56 @@ func (s *Service) backfillUsedPricelistItemCategories(pricelistRepo *repository.
} }
} }
// ListActiveViewersByConfigUUIDs returns a map of configUUID → []username for users
// who currently have those configs open (based on the last two sync cycles).
func (s *Service) ListActiveViewersByConfigUUIDs(uuids []string) (map[string][]string, error) {
if len(uuids) == 0 {
return map[string][]string{}, nil
}
mariaDB, err := s.getDB()
if err != nil || mariaDB == nil {
return map[string][]string{}, nil
}
selfUsername := strings.ToLower(strings.TrimSpace(s.localDB.GetDBUser()))
type row struct {
Username string `gorm:"column:username"`
OpenConfigJSON string `gorm:"column:open_config_uuids"`
}
var rows []row
if err := mariaDB.Raw(`
SELECT username, open_config_uuids
FROM qt_client_schema_state
WHERE open_config_uuids IS NOT NULL
AND open_config_uuids != '[]'
AND last_checked_at > NOW() - INTERVAL 10 MINUTE
`).Scan(&rows).Error; err != nil {
return map[string][]string{}, nil
}
wantSet := make(map[string]struct{}, len(uuids))
for _, u := range uuids {
wantSet[u] = struct{}{}
}
result := make(map[string][]string)
for _, r := range rows {
if strings.ToLower(strings.TrimSpace(r.Username)) == selfUsername {
continue
}
var openUUIDs []string
if err := json.Unmarshal([]byte(r.OpenConfigJSON), &openUUIDs); err != nil {
continue
}
for _, ou := range openUUIDs {
if _, ok := wantSet[ou]; ok {
result[ou] = append(result[ou], r.Username)
}
}
}
return result, nil
}
// ListUserSyncStatuses returns users who have recorded a client schema state check. // ListUserSyncStatuses returns users who have recorded a client schema state check.
func (s *Service) ListUserSyncStatuses(onlineThreshold time.Duration) ([]UserSyncStatus, error) { func (s *Service) ListUserSyncStatuses(onlineThreshold time.Duration) ([]UserSyncStatus, error) {
mariaDB, err := s.getDB() mariaDB, err := s.getDB()

View File

@@ -0,0 +1,15 @@
# QuoteForge v2.24
Дата релиза: 2026-06-26
Тег: `v2.24`
## Что нового
### Исправления
- **Исправлен ReferenceError в конфигураторе при выборе компонента в секционированных вкладках (Storage, PCI).** Переменная `sectionCategories` не была определена — опечатка, должно быть `section.categories`. Ошибка возникала при клике на элемент автокомплита во вкладках с секциями.
## Запуск на macOS
Снимите карантинный атрибут через терминал: `xattr -d com.apple.quarantine /path/to/qfs-darwin-arm64`
После этого бинарник запустится без предупреждения Gatekeeper.

View File

@@ -0,0 +1,17 @@
# QuoteForge v2.25
Дата релиза: 2026-06-29
Тег: `v2.25`
Предыдущий релиз: `v2.24`
## Ключевые изменения
- исправлено дублирование позиций в таблице «Цена покупки» и в экспорте CSV: сопоставление LOT между BOM и корзиной теперь регистронезависимое;
- нормализация LOT-маппингов BOM сведена в единую каноничную функцию на бэкенде (UPPERCASE + схлопывание дублей) — устранены разошедшиеся копии, дававшие разный результат на фронте и в CSV;
- единый источник категории LOT — `local_pricelist_items.lot_category`; удалён неиспользуемый серверный слой управления компонентами/категориями.
## Запуск на macOS
Снимите карантинный атрибут через терминал: `xattr -d com.apple.quarantine /path/to/qfs-darwin-arm64`
После этого бинарник запустится без предупреждения Gatekeeper.

View File

@@ -0,0 +1,17 @@
# QuoteForge v2.26
Дата релиза: 2026-06-29
Тег: `v2.26`
Предыдущий релиз: `v2.25`
## Ключевые изменения
- fix: лоты, отсутствующие в текущем прайслисте, больше не блокируют сохранение конфига и генерацию артикула — такие лоты просто пропускаются;
- fix: если прайслист конфига удалён с сервера, автоматически выбирается последний активный;
- refactor: удалён мёртвый код qt_lot_metadata;
## Запуск на macOS
Снимите карантинный атрибут через терминал: `xattr -d com.apple.quarantine /path/to/qfs-darwin-arm64`
После этого бинарник запустится без предупреждения Gatekeeper.

View File

@@ -242,6 +242,7 @@ function renderConfigs(configs) {
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Цена (за 1 шт)</th>'; html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Цена (за 1 шт)</th>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Кол-во</th>'; html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Кол-во</th>';
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Сумма</th>'; html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Сумма</th>';
html += '<th class="px-2 py-3 w-8"></th>';
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>'; html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
html += '</tr></thead><tbody class="divide-y">'; html += '</tr></thead><tbody class="divide-y">';
@@ -298,6 +299,16 @@ function renderConfigs(configs) {
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>';
html += '<td class="px-4 py-3 text-sm text-gray-500">' + serverCount + '</td>'; html += '<td class="px-4 py-3 text-sm text-gray-500">' + serverCount + '</td>';
html += '<td class="px-4 py-3 text-sm text-right">' + total + '</td>'; html += '<td class="px-4 py-3 text-sm text-right">' + total + '</td>';
const viewers = c.viewers || [];
if (viewers.length > 0) {
const names = viewers.map(escapeHtml).join(', ');
html += '<td class="px-2 py-3 text-center w-8">';
html += '<span title="Открыта: ' + names + '" class="inline-flex items-center justify-center text-blue-500 cursor-default">';
html += '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg>';
html += '</span></td>';
} else {
html += '<td class="px-2 py-3 w-8"></td>';
}
html += '<td class="px-4 py-3 text-sm text-right space-x-2">'; html += '<td class="px-4 py-3 text-sm text-right space-x-2">';
if (configStatusMode === 'archived') { if (configStatusMode === 'archived') {
html += '<button onclick="reactivateConfig(\'' + c.uuid + '\')" class="text-emerald-600 hover:text-emerald-800" title="Восстановить">'; html += '<button onclick="reactivateConfig(\'' + c.uuid + '\')" class="text-emerald-600 hover:text-emerald-800" title="Восстановить">';

View File

@@ -985,6 +985,16 @@ document.addEventListener('DOMContentLoaded', async function() {
if (configUUID) { if (configUUID) {
loadVendorSpec(configUUID); loadVendorSpec(configUUID);
} }
// Presence: announce that this config is open and keep renewing every 4 min
if (configUUID) {
const sendPresence = () => fetch('/api/configs/' + configUUID + '/presence', {method: 'POST'}).catch(() => {});
sendPresence();
setInterval(sendPresence, 4 * 60 * 1000);
window.addEventListener('beforeunload', () => {
fetch('/api/configs/' + configUUID + '/presence', {method: 'DELETE', keepalive: true}).catch(() => {});
});
}
}); });
async function loadAllComponents() { async function loadAllComponents() {
@@ -3379,8 +3389,9 @@ function setBOMManualLotDraft(rowIdx, value, el) {
function _getRowAllocations(row) { function _getRowAllocations(row) {
const list = Array.isArray(row?.lot_allocations) ? row.lot_allocations : []; const list = Array.isArray(row?.lot_allocations) ? row.lot_allocations : [];
// Canonical LOT identity is UPPERCASE (see NormalizeLotName on the backend).
return list.map(a => ({ return list.map(a => ({
lot_name: (a?.lot_name || '').trim(), lot_name: (a?.lot_name || '').trim().toUpperCase(),
quantity: Math.max(1, parseInt(a?.quantity, 10) || 1) quantity: Math.max(1, parseInt(a?.quantity, 10) || 1)
})); }));
} }
@@ -3389,9 +3400,11 @@ function _getRowLotQtyPerPN(row) {
return (Number.isFinite(q) && q > 0) ? q : 1; return (Number.isFinite(q) && q > 0) ? q : 1;
} }
function _getRowBaseLot(row) { function _getRowBaseLot(row) {
if (row?.resolved_lot) return row.resolved_lot; // Canonical LOT identity is UPPERCASE (see NormalizeLotName on the backend).
const resolved = (row?.resolved_lot || '').trim();
if (resolved) return resolved.toUpperCase();
const manual = (row?.manual_lot || '').trim(); const manual = (row?.manual_lot || '').trim();
if (manual && _bomLotValid(manual)) return manual; if (manual && _bomLotValid(manual)) return manual.toUpperCase();
return ''; return '';
} }
function _getRowCanonicalLotMappings(row) { function _getRowCanonicalLotMappings(row) {
@@ -4039,40 +4052,39 @@ async function renderPricingTab() {
const tfootSale = document.getElementById('pricing-foot-sale'); const tfootSale = document.getElementById('pricing-foot-sale');
const cart = window._currentCart || []; const cart = window._currentCart || [];
// Canonical LOT key: matching/dedup must be case-insensitive (cart is UPPERCASE,
// BOM mappings may be mixed-case). See NormalizeLotName on the backend.
const U = s => (s || '').toUpperCase();
const compMap = {}; const compMap = {};
(window._bomAllComponents || allComponents).forEach(c => { compMap[c.lot_name] = c; }); (window._bomAllComponents || allComponents).forEach(c => { compMap[U(c.lot_name)] = c; });
const rowBaseLot = (row) => { const rowBaseLot = (row) => _getRowBaseLot(row);
if (row?.resolved_lot) return row.resolved_lot;
if (row?.manual_lot && _bomLotValid(row.manual_lot)) return row.manual_lot;
return '';
};
// Collect LOTs to price: from BOM rows (resolved) or from cart // Collect LOTs to price: from BOM rows (resolved) or from cart
// Use cart quantity when available (source of truth); fall back to BOM-computed quantity. // Use cart quantity when available (source of truth); fall back to BOM-computed quantity.
const _cartQtyMap = {}; const _cartQtyMap = {};
cart.forEach(item => { if (item?.lot_name) _cartQtyMap[item.lot_name] = item.quantity; }); cart.forEach(item => { if (item?.lot_name) _cartQtyMap[U(item.lot_name)] = item.quantity; });
let itemsForPriceLevels = []; let itemsForPriceLevels = [];
if (bomRows.length) { if (bomRows.length) {
const seen = new Set(); const seen = new Set();
bomRows.forEach(row => { bomRows.forEach(row => {
const baseLot = rowBaseLot(row); const baseLot = rowBaseLot(row);
const allocs = _getRowAllocations(row).filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1); const allocs = _getRowAllocations(row).filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1);
if (baseLot && !seen.has(baseLot)) { if (baseLot && !seen.has(U(baseLot))) {
seen.add(baseLot); seen.add(U(baseLot));
itemsForPriceLevels.push({ lot_name: baseLot, quantity: _cartQtyMap[baseLot] ?? (row.quantity * _getRowLotQtyPerPN(row)) }); itemsForPriceLevels.push({ lot_name: baseLot, quantity: _cartQtyMap[U(baseLot)] ?? (row.quantity * _getRowLotQtyPerPN(row)) });
} }
if (allocs.length) { if (allocs.length) {
allocs.forEach(a => { allocs.forEach(a => {
if (!seen.has(a.lot_name)) { if (!seen.has(U(a.lot_name))) {
seen.add(a.lot_name); seen.add(U(a.lot_name));
itemsForPriceLevels.push({ lot_name: a.lot_name, quantity: _cartQtyMap[a.lot_name] ?? (row.quantity * a.quantity) }); itemsForPriceLevels.push({ lot_name: a.lot_name, quantity: _cartQtyMap[U(a.lot_name)] ?? (row.quantity * a.quantity) });
} }
}); });
} }
}); });
cart.forEach(item => { cart.forEach(item => {
if (!item?.lot_name || seen.has(item.lot_name)) return; if (!item?.lot_name || seen.has(U(item.lot_name))) return;
seen.add(item.lot_name); seen.add(U(item.lot_name));
itemsForPriceLevels.push({ lot_name: item.lot_name, quantity: item.quantity }); itemsForPriceLevels.push({ lot_name: item.lot_name, quantity: item.quantity });
}); });
} else { } else {
@@ -4097,7 +4109,7 @@ async function renderPricingTab() {
}); });
if (resp.ok) { if (resp.ok) {
const data = await resp.json(); const data = await resp.json();
(data.items || []).forEach(i => { priceMap[i.lot_name] = i; }); (data.items || []).forEach(i => { priceMap[U(i.lot_name)] = i; });
} }
} catch(e) { /* silent */ } } catch(e) { /* silent */ }
} }
@@ -4119,19 +4131,19 @@ async function renderPricingTab() {
// ─── Build shared row data (unit prices for display, totals for math) ──── // ─── Build shared row data (unit prices for display, totals for math) ────
// Each BOM row is exploded into per-LOT sub-rows; grouped by vendor PN via groupStart/groupSize. // Each BOM row is exploded into per-LOT sub-rows; grouped by vendor PN via groupStart/groupSize.
const cartQtyMap = {}; const cartQtyMap = {};
cart.forEach(item => { if (item?.lot_name) cartQtyMap[item.lot_name] = item.quantity; }); cart.forEach(item => { if (item?.lot_name) cartQtyMap[U(item.lot_name)] = item.quantity; });
const _buildRows = () => { const _buildRows = () => {
const result = []; const result = [];
const coveredLots = new Set(); const coveredLots = new Set();
const _pushCartRow = (item, isEstOnly) => { const _pushCartRow = (item, isEstOnly) => {
const pl = priceMap[item.lot_name]; const pl = priceMap[U(item.lot_name)];
const u = _getUnitPrices(pl); const u = _getUnitPrices(pl);
const estUnit = u.estUnit > 0 ? u.estUnit : (item.unit_price || 0); const estUnit = u.estUnit > 0 ? u.estUnit : (item.unit_price || 0);
result.push({ result.push({
lotCell: escapeHtml(item.lot_name), lotText: item.lot_name, lotCell: escapeHtml(item.lot_name), lotText: item.lot_name,
vendorPN: null, vendorPN: null,
desc: (compMap[item.lot_name] || {}).description || '', desc: (compMap[U(item.lot_name)] || {}).description || '',
qty: item.quantity, qty: item.quantity,
estUnit, warehouseUnit: u.warehouseUnit, competitorUnit: u.competitorUnit, estUnit, warehouseUnit: u.warehouseUnit, competitorUnit: u.competitorUnit,
est: estUnit * item.quantity, est: estUnit * item.quantity,
@@ -4148,28 +4160,28 @@ async function renderPricingTab() {
const catB = ciStr(b.category); const catB = ciStr(b.category);
return (categoryOrderMap[catA] || 9999) - (categoryOrderMap[catB] || 9999); return (categoryOrderMap[catA] || 9999) - (categoryOrderMap[catB] || 9999);
}); });
sortedByCategory.forEach(item => { _pushCartRow(item, false); coveredLots.add(item.lot_name); }); sortedByCategory.forEach(item => { _pushCartRow(item, false); coveredLots.add(U(item.lot_name)); });
return { result, coveredLots }; return { result, coveredLots };
} }
bomRows.forEach(row => { bomRows.forEach(row => {
const baseLot = rowBaseLot(row); const baseLot = rowBaseLot(row);
const allocs = _getRowAllocations(row).filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1); const allocs = _getRowAllocations(row).filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1);
if (baseLot) coveredLots.add(baseLot); if (baseLot) coveredLots.add(U(baseLot));
allocs.forEach(a => coveredLots.add(a.lot_name)); allocs.forEach(a => coveredLots.add(U(a.lot_name)));
const vendorOrigUnit = row.unit_price != null ? row.unit_price const vendorOrigUnit = row.unit_price != null ? row.unit_price
: (row.total_price != null && row.quantity > 0 ? row.total_price / row.quantity : null); : (row.total_price != null && row.quantity > 0 ? row.total_price / row.quantity : null);
const vendorOrig = row.total_price != null ? row.total_price const vendorOrig = row.total_price != null ? row.total_price
: (row.unit_price != null ? row.unit_price * row.quantity : null); : (row.unit_price != null ? row.unit_price * row.quantity : null);
const desc = row.description || (baseLot ? ((compMap[baseLot] || {}).description || '') : ''); const desc = row.description || (baseLot ? ((compMap[U(baseLot)] || {}).description || '') : '');
// Build per-LOT sub-rows // Build per-LOT sub-rows
const subRows = []; const subRows = [];
if (baseLot) { if (baseLot) {
const u = _getUnitPrices(priceMap[baseLot]); const u = _getUnitPrices(priceMap[U(baseLot)]);
const lotQty = _getRowLotQtyPerPN(row); const lotQty = _getRowLotQtyPerPN(row);
const qty = cartQtyMap[baseLot] ?? (row.quantity * lotQty); const qty = cartQtyMap[U(baseLot)] ?? (row.quantity * lotQty);
subRows.push({ subRows.push({
lotCell: escapeHtml(baseLot), lotText: baseLot, qty, lotCell: escapeHtml(baseLot), lotText: baseLot, qty,
estUnit: u.estUnit > 0 ? u.estUnit : 0, estUnit: u.estUnit > 0 ? u.estUnit : 0,
@@ -4180,8 +4192,8 @@ async function renderPricingTab() {
}); });
} }
allocs.forEach(a => { allocs.forEach(a => {
const u = _getUnitPrices(priceMap[a.lot_name]); const u = _getUnitPrices(priceMap[U(a.lot_name)]);
const qty = cartQtyMap[a.lot_name] ?? (row.quantity * a.quantity); const qty = cartQtyMap[U(a.lot_name)] ?? (row.quantity * a.quantity);
subRows.push({ subRows.push({
lotCell: escapeHtml(a.lot_name), lotText: a.lot_name, qty, lotCell: escapeHtml(a.lot_name), lotText: a.lot_name, qty,
estUnit: u.estUnit > 0 ? u.estUnit : 0, estUnit: u.estUnit > 0 ? u.estUnit : 0,
@@ -4223,9 +4235,9 @@ async function renderPricingTab() {
// Estimate-only LOTs (cart items not covered by BOM) // Estimate-only LOTs (cart items not covered by BOM)
cart.forEach(item => { cart.forEach(item => {
if (!item?.lot_name || coveredLots.has(item.lot_name)) return; if (!item?.lot_name || coveredLots.has(U(item.lot_name))) return;
_pushCartRow(item, true); _pushCartRow(item, true);
coveredLots.add(item.lot_name); coveredLots.add(U(item.lot_name));
}); });
return { result, coveredLots }; return { result, coveredLots };

View File

@@ -518,7 +518,16 @@ function renderConfigs(configs) {
html += '<td class="px-4 py-3 text-sm text-gray-500"><input type="number" min="1" value="' + serverCount + '" class="w-16 px-1 py-0.5 border rounded text-center text-sm" data-uuid="' + c.uuid + '" data-prev="' + serverCount + '" onchange="updateConfigServerCount(this)"></td>'; html += '<td class="px-4 py-3 text-sm text-gray-500"><input type="number" min="1" value="' + serverCount + '" class="w-16 px-1 py-0.5 border rounded text-center text-sm" data-uuid="' + c.uuid + '" data-prev="' + serverCount + '" onchange="updateConfigServerCount(this)"></td>';
} }
html += '<td class="px-4 py-3 text-sm text-right" data-total-uuid="' + c.uuid + '">' + formatMoneyNoDecimals(total) + '</td>'; html += '<td class="px-4 py-3 text-sm text-right" data-total-uuid="' + c.uuid + '">' + formatMoneyNoDecimals(total) + '</td>';
html += '<td class="px-2 py-3 text-sm text-center text-gray-500 w-12">main</td>'; const projViewers = c.viewers || [];
if (projViewers.length > 0) {
const projNames = projViewers.map(escapeHtml).join(', ');
html += '<td class="px-2 py-3 text-center w-12">';
html += '<span title="Открыта: ' + projNames + '" class="inline-flex items-center justify-center text-blue-500 cursor-default">';
html += '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg>';
html += '</span></td>';
} else {
html += '<td class="px-2 py-3 w-12"></td>';
}
html += '<td class="px-4 py-3 text-sm text-right whitespace-nowrap"><div class="inline-flex items-center justify-end gap-2">'; html += '<td class="px-4 py-3 text-sm text-right whitespace-nowrap"><div class="inline-flex items-center justify-end gap-2">';
if (configStatusMode === 'archived') { if (configStatusMode === 'archived') {
html += '<button onclick="reactivateConfig(\'' + c.uuid + '\')" class="text-emerald-600 hover:text-emerald-800" title="Восстановить">'; html += '<button onclick="reactivateConfig(\'' + c.uuid + '\')" class="text-emerald-600 hover:text-emerald-800" title="Восстановить">';