Compare commits

..

5 Commits
v2.25 ... 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
17 changed files with 283 additions and 209 deletions

View File

@@ -996,8 +996,27 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
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{
"configurations": cfgs,
"configurations": rows,
"total": total,
"page": page,
"per_page": perPage,
@@ -1332,6 +1351,16 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
}
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")
@@ -1672,8 +1701,32 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
}
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.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) {

View File

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

View File

@@ -1,7 +1,6 @@
package article
import (
"errors"
"path/filepath"
"testing"
"time"
@@ -9,7 +8,7 @@ import (
"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"))
if err != nil {
t.Fatalf("init local db: %v", err)
@@ -36,73 +35,60 @@ func TestResolveLotCategoriesStrict_MissingCategoryReturnsError(t *testing.T) {
t.Fatalf("save local items: %v", err)
}
_, err = ResolveLotCategoriesStrict(local, 1, []string{"CPU_A"})
if err == nil {
t.Fatalf("expected error")
cats, err := ResolveLotCategories(local, 1, []string{"CPU_A", "UNKNOWN"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !errors.Is(err, ErrMissingCategoryForLot) {
t.Fatalf("expected ErrMissingCategoryForLot, got %v", err)
if cats["CPU_A"] != "" {
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"))
if err != nil {
t.Fatalf("init local db: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
// Older pricelist used by the configuration — CPU_B has no category here
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
ServerID: 2,
ServerID: 1,
Source: "estimate",
Version: "S-2026-02-11-002",
Name: "old",
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",
Version: "S-2026-02-11-001",
Name: "test",
IsActive: true,
CreatedAt: time.Now(),
SyncedAt: time.Now(),
}); 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 {
t.Fatalf("get latest pricelist: %v", err)
t.Fatalf("get pricelist: %v", err)
}
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 {
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 {
t.Fatalf("expected fallback, got error: %v", err)
t.Fatalf("unexpected error: %v", err)
}
if cats["CPU_B"] != "CPU" {
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) {

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")
}
cats, err := ResolveLotCategoriesStrict(local, *opts.ServerPricelist, lotNames)
cats, err := ResolveLotCategories(local, *opts.ServerPricelist, lotNames)
if err != nil {
return BuildResult{}, err
}

View File

@@ -330,14 +330,3 @@ func LocalToPricelistItem(local *LocalPricelistItem, serverPricelistID uint) *mo
}
}
// 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

@@ -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
func (l *LocalDB) CountLocalPricelists() 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{} {
return []interface{}{
&Category{},
&LotMetadata{},
&Project{},
&Configuration{},
&Pricelist{},

View File

@@ -1,9 +1,5 @@
package services
import (
"git.mchus.pro/mchus/quoteforge/internal/models"
)
type ComponentListResult struct {
Items []ComponentView `json:"items"`
TotalCount int64 `json:"total_count"`
@@ -13,12 +9,9 @@ type ComponentListResult struct {
}
type ComponentView struct {
LotName string `json:"lot_name"`
Description string `json:"description"`
Category string `json:"category"`
CategoryName string `json:"category_name"`
Model string `json:"model"`
PriceFreshness models.PriceFreshness `json:"price_freshness"`
PopularityScore float64 `json:"popularity_score"`
Specs models.Specs `json:"specs,omitempty"`
LotName string `json:"lot_name"`
Description string `json:"description"`
Category string `json:"category"`
CategoryName string `json:"category_name"`
Model string `json:"model"`
}

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()

View File

@@ -249,6 +249,13 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
pricelistItemsCount := s.localDB.CountAllPricelistItems()
componentsCount := s.localDB.CountComponents()
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(`
INSERT INTO qt_client_schema_state (
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,
last_sync_error_code, last_sync_error_text,
local_pricelist_count, pricelist_items_count, components_count, db_size_bytes,
open_config_uuids,
last_checked_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
app_version = VALUES(app_version),
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),
components_count = VALUES(components_count),
db_size_bytes = VALUES(db_size_bytes),
open_config_uuids = VALUES(open_config_uuids),
last_checked_at = VALUES(last_checked_at),
updated_at = VALUES(updated_at)
`, username, hostname, appmeta.Version(),
@@ -285,6 +294,7 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
estimateVersion, warehouseVersion, competitorVersion,
lastSyncErrorCode, lastSyncErrorText,
localPricelistCount, pricelistItemsCount, componentsCount, dbSizeBytes,
openConfigUUIDsJSON,
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.
func (s *Service) ListUserSyncStatuses(onlineThreshold time.Duration) ([]UserSyncStatus, error) {
mariaDB, err := s.getDB()

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">Кол-во</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 += '</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">' + serverCount + '</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">';
if (configStatusMode === 'archived') {
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) {
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() {

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-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">';
if (configStatusMode === 'archived') {
html += '<button onclick="reactivateConfig(\'' + c.uuid + '\')" class="text-emerald-600 hover:text-emerald-800" title="Восстановить">';