9 Commits

Author SHA1 Message Date
Mikhail Chusavitin
9b5d57902d Add project variants and UI updates 2026-02-13 19:27:48 +03:00
Mikhail Chusavitin
4e1a46bd71 Fix project selection and add project settings UI 2026-02-13 12:51:53 +03:00
Mikhail Chusavitin
857ec7a0e5 Fix article category fallback for pricelist gaps 2026-02-12 16:47:49 +03:00
Mikhail Chusavitin
01f21fa5ac Document backup implementation guide 2026-02-11 19:50:35 +03:00
Mikhail Chusavitin
a1edca3be9 Add scheduled rotating local backups 2026-02-11 19:48:40 +03:00
Mikhail Chusavitin
7fbf813952 docs: add release notes for v1.3.0 2026-02-11 19:27:16 +03:00
Mikhail Chusavitin
e58fd35ee4 Refine article compression and simplify generator 2026-02-11 19:24:25 +03:00
Mikhail Chusavitin
e3559035f7 Allow cross-user project updates 2026-02-11 19:24:16 +03:00
Mikhail Chusavitin
5edffe822b Add article generation and pricelist categories 2026-02-11 19:16:01 +03:00
54 changed files with 4377 additions and 487 deletions

View File

@@ -446,6 +446,8 @@ CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/qfs ./cmd/qfs
| `QFS_DB_PATH` | Полный путь к локальной SQLite БД | OS-specific user state dir |
| `QFS_STATE_DIR` | Каталог state (если `QFS_DB_PATH` не задан) | OS-specific user state dir |
| `QFS_CONFIG_PATH` | Полный путь к `config.yaml` | OS-specific user state dir |
| `QFS_BACKUP_DIR` | Каталог для ротационных бэкапов локальных данных | `<db dir>/backups` |
| `QFS_BACKUP_DISABLE` | Отключить автоматические бэкапы (`1/true/yes`) | — |
## Интеграция с существующей БД

View File

@@ -163,7 +163,7 @@ func buildPlan(db *gorm.DB, fallbackOwner string) ([]migrationAction, map[string
}
for i := range projects {
p := projects[i]
existingProjects[projectKey(p.OwnerUsername, p.Name)] = &p
existingProjects[projectKey(p.OwnerUsername, derefString(p.Name))] = &p
}
}
@@ -253,12 +253,13 @@ func executePlan(db *gorm.DB, actions []migrationAction, existingProjects map[st
for _, action := range actions {
key := projectKey(action.OwnerUsername, action.TargetProjectName)
project := projectCache[key]
project := projectCache[key]
if project == nil {
project = &models.Project{
UUID: uuid.NewString(),
OwnerUsername: action.OwnerUsername,
Name: action.TargetProjectName,
Code: action.TargetProjectName,
Name: ptrString(action.TargetProjectName),
IsActive: true,
IsSystem: false,
}
@@ -268,7 +269,7 @@ func executePlan(db *gorm.DB, actions []migrationAction, existingProjects map[st
projectCache[key] = project
} else if !project.IsActive {
if err := tx.Model(&models.Project{}).Where("uuid = ?", project.UUID).Update("is_active", true).Error; err != nil {
return fmt.Errorf("reactivate project %s (%s): %w", project.Name, project.UUID, err)
return fmt.Errorf("reactivate project %s (%s): %w", derefString(project.Name), project.UUID, err)
}
project.IsActive = true
}
@@ -294,3 +295,14 @@ func setKeys(set map[string]struct{}) []string {
func projectKey(owner, name string) string {
return owner + "||" + name
}
func derefString(value *string) string {
if value == nil {
return ""
}
return *value
}
func ptrString(value string) *string {
return &value
}

View File

@@ -50,6 +50,7 @@ const onDemandPullCooldown = 30 * time.Second
func main() {
configPath := flag.String("config", "", "path to config file (default: user state dir or QFS_CONFIG_PATH)")
localDBPath := flag.String("localdb", "", "path to local SQLite database (default: user state dir or QFS_DB_PATH)")
resetLocalDB := flag.Bool("reset-localdb", false, "reset local SQLite data on startup (keeps connection settings)")
migrate := flag.Bool("migrate", false, "run database migrations")
version := flag.Bool("version", false, "show version information")
flag.Parse()
@@ -100,6 +101,13 @@ func main() {
}
}
if shouldResetLocalDB(*resetLocalDB) {
if err := localdb.ResetData(resolvedLocalDBPath); err != nil {
slog.Error("failed to reset local database", "error", err)
os.Exit(1)
}
}
// Initialize local SQLite database (always used)
local, err := localdb.New(resolvedLocalDBPath)
if err != nil {
@@ -232,6 +240,10 @@ func main() {
syncWorker := sync.NewWorker(syncService, connMgr, backgroundSyncInterval)
go syncWorker.Start(workerCtx)
backupCtx, backupCancel := context.WithCancel(context.Background())
defer backupCancel()
go startBackupScheduler(backupCtx, cfg, resolvedLocalDBPath, resolvedConfigPath)
srv := &http.Server{
Addr: cfg.Address(),
Handler: router,
@@ -274,6 +286,7 @@ func main() {
// Stop background sync worker first
syncWorker.Stop()
workerCancel()
backupCancel()
// Then shutdown HTTP server
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -290,6 +303,31 @@ func main() {
}
}
func shouldResetLocalDB(flagValue bool) bool {
if flagValue {
return true
}
value := strings.TrimSpace(os.Getenv("QFS_RESET_LOCAL_DB"))
if value == "" {
return false
}
switch strings.ToLower(value) {
case "1", "true", "yes", "y":
return true
default:
return false
}
}
func derefString(value *string) string {
if value == nil {
return ""
}
return *value
}
func setConfigDefaults(cfg *config.Config) {
if cfg.Server.Host == "" {
cfg.Server.Host = "127.0.0.1"
@@ -324,6 +362,9 @@ func setConfigDefaults(cfg *config.Config) {
if cfg.Pricing.MinQuotesForMedian == 0 {
cfg.Pricing.MinQuotesForMedian = 3
}
if cfg.Backup.Time == "" {
cfg.Backup.Time = "00:00"
}
}
func ensureDefaultConfigFile(configPath string) error {
@@ -347,6 +388,9 @@ func ensureDefaultConfigFile(configPath string) error {
read_timeout: 30s
write_timeout: 30s
backup:
time: "00:00"
logging:
level: "info"
format: "json"
@@ -373,9 +417,14 @@ type runtimeLoggingConfig struct {
Output string `yaml:"output"`
}
type runtimeBackupConfig struct {
Time string `yaml:"time"`
}
type runtimeConfigFile struct {
Server runtimeServerConfig `yaml:"server"`
Logging runtimeLoggingConfig `yaml:"logging"`
Backup runtimeBackupConfig `yaml:"backup"`
}
// migrateConfigFileToRuntimeShape rewrites config.yaml in a minimal runtime format.
@@ -398,6 +447,9 @@ func migrateConfigFileToRuntimeShape(configPath string, cfg *config.Config) erro
Format: cfg.Logging.Format,
Output: cfg.Logging.Output,
},
Backup: runtimeBackupConfig{
Time: cfg.Backup.Time,
},
}
rendered, err := yaml.Marshal(&runtimeCfg)
@@ -416,6 +468,69 @@ func migrateConfigFileToRuntimeShape(configPath string, cfg *config.Config) erro
return nil
}
func startBackupScheduler(ctx context.Context, cfg *config.Config, dbPath, configPath string) {
if cfg == nil {
return
}
hour, minute, err := parseBackupTime(cfg.Backup.Time)
if err != nil {
slog.Warn("invalid backup time; using 00:00", "value", cfg.Backup.Time, "error", err)
hour = 0
minute = 0
}
if created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath); backupErr != nil {
slog.Error("local backup failed", "error", backupErr)
} else if len(created) > 0 {
for _, path := range created {
slog.Info("local backup completed", "archive", path)
}
}
for {
next := nextBackupTime(time.Now(), hour, minute)
timer := time.NewTimer(time.Until(next))
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
start := time.Now()
created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath)
duration := time.Since(start)
if backupErr != nil {
slog.Error("local backup failed", "error", backupErr, "duration", duration)
} else {
for _, path := range created {
slog.Info("local backup completed", "archive", path, "duration", duration)
}
}
}
}
}
func parseBackupTime(value string) (int, int, error) {
if strings.TrimSpace(value) == "" {
return 0, 0, fmt.Errorf("empty backup time")
}
parsed, err := time.Parse("15:04", value)
if err != nil {
return 0, 0, err
}
return parsed.Hour(), parsed.Minute(), nil
}
func nextBackupTime(now time.Time, hour, minute int) time.Time {
location := now.Location()
target := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, location)
if !now.Before(target) {
target = target.Add(24 * time.Hour)
}
return target
}
// runSetupMode starts a minimal server that only serves the setup page
func runSetupMode(local *localdb.LocalDB) {
restartSig := make(chan struct{}, 1)
@@ -926,6 +1041,23 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
c.JSON(http.StatusCreated, config)
})
configs.POST("/preview-article", func(c *gin.Context) {
var req services.ArticlePreviewRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
result, err := configService.BuildArticlePreview(&req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"article": result.Article,
"warnings": result.Warnings,
})
})
configs.GET("/:uuid", func(c *gin.Context) {
uuid := c.Param("uuid")
config, err := configService.GetByUUIDNoAuth(uuid)
@@ -1207,7 +1339,10 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if status == "archived" && p.IsActive {
continue
}
if search != "" && !strings.Contains(strings.ToLower(p.Name), search) {
if search != "" &&
!strings.Contains(strings.ToLower(derefString(p.Name)), search) &&
!strings.Contains(strings.ToLower(p.Code), search) &&
!strings.Contains(strings.ToLower(p.Variant), search) {
continue
}
if author != "" && !strings.Contains(strings.ToLower(strings.TrimSpace(p.OwnerUsername)), author) {
@@ -1220,8 +1355,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
left := filtered[i]
right := filtered[j]
if sortField == "name" {
leftName := strings.ToLower(strings.TrimSpace(left.Name))
rightName := strings.ToLower(strings.TrimSpace(right.Name))
leftName := strings.ToLower(strings.TrimSpace(derefString(left.Name)))
rightName := strings.ToLower(strings.TrimSpace(derefString(right.Name)))
if leftName == rightName {
if sortDir == "asc" {
return left.CreatedAt.Before(right.CreatedAt)
@@ -1234,8 +1369,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
return leftName > rightName
}
if left.CreatedAt.Equal(right.CreatedAt) {
leftName := strings.ToLower(strings.TrimSpace(left.Name))
rightName := strings.ToLower(strings.TrimSpace(right.Name))
leftName := strings.ToLower(strings.TrimSpace(derefString(left.Name)))
rightName := strings.ToLower(strings.TrimSpace(derefString(right.Name)))
if sortDir == "asc" {
return leftName < rightName
}
@@ -1294,6 +1429,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
"id": p.ID,
"uuid": p.UUID,
"owner_username": p.OwnerUsername,
"code": p.Code,
"variant": p.Variant,
"name": p.Name,
"tracker_url": p.TrackerURL,
"is_active": p.IsActive,
@@ -1319,31 +1456,36 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
})
})
// GET /api/projects/all - Returns all projects without pagination for UI dropdowns
projects.GET("/all", func(c *gin.Context) {
allProjects, err := projectService.ListByUser(dbUsername, true)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// GET /api/projects/all - Returns all projects without pagination for UI dropdowns
projects.GET("/all", func(c *gin.Context) {
allProjects, err := projectService.ListByUser(dbUsername, true)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Return simplified list of all projects (UUID + Name only)
type ProjectSimple struct {
UUID string `json:"uuid"`
Code string `json:"code"`
Variant string `json:"variant"`
Name string `json:"name"`
IsActive bool `json:"is_active"`
}
// Return simplified list of all projects (UUID + Name only)
type ProjectSimple struct {
UUID string `json:"uuid"`
Name string `json:"name"`
}
simplified := make([]ProjectSimple, 0, len(allProjects))
for _, p := range allProjects {
simplified = append(simplified, ProjectSimple{
UUID: p.UUID,
Code: p.Code,
Variant: p.Variant,
Name: derefString(p.Name),
IsActive: p.IsActive,
})
}
simplified := make([]ProjectSimple, 0, len(allProjects))
for _, p := range allProjects {
simplified = append(simplified, ProjectSimple{
UUID: p.UUID,
Name: p.Name,
})
}
c.JSON(http.StatusOK, simplified)
})
c.JSON(http.StatusOK, simplified)
})
projects.POST("", func(c *gin.Context) {
var req services.CreateProjectRequest
@@ -1351,13 +1493,18 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if strings.TrimSpace(req.Name) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "project name is required"})
if strings.TrimSpace(req.Code) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "project code is required"})
return
}
project, err := projectService.Create(dbUsername, &req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
switch {
case errors.Is(err, services.ErrProjectCodeExists):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
c.JSON(http.StatusCreated, project)
@@ -1385,13 +1532,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if strings.TrimSpace(req.Name) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "project name is required"})
return
}
project, err := projectService.Update(c.Param("uuid"), dbUsername, &req)
if err != nil {
switch {
case errors.Is(err, services.ErrProjectCodeExists):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, services.ErrProjectForbidden):

View File

@@ -149,7 +149,7 @@ func TestProjectArchiveHidesConfigsAndCloneIntoProject(t *testing.T) {
t.Fatalf("setup router: %v", err)
}
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"P1"}`)))
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"P1","code":"P1"}`)))
createProjectReq.Header.Set("Content-Type", "application/json")
createProjectRec := httptest.NewRecorder()
router.ServeHTTP(createProjectRec, createProjectReq)
@@ -243,7 +243,7 @@ func TestConfigMoveToProjectEndpoint(t *testing.T) {
t.Fatalf("setup router: %v", err)
}
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"Move Project"}`)))
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"Move Project","code":"MOVE"}`)))
createProjectReq.Header.Set("Content-Type", "application/json")
createProjectRec := httptest.NewRecorder()
router.ServeHTTP(createProjectRec, createProjectReq)

View File

@@ -37,6 +37,9 @@ export:
max_file_age: "1h"
company_name: "Your Company Name"
backup:
time: "00:00"
alerts:
enabled: true
check_interval: "1h"

View File

@@ -1,297 +0,0 @@
# CSV Export Pattern (Go + GORM)
## Архитектура (3-слойная)
### 1. Handler Layer (HTTP)
**Задачи**: Обработка HTTP-запроса, установка заголовков, инициация экспорта
```go
func (h *PricelistHandler) ExportCSV(c *gin.Context) {
// 1. Валидация параметров
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
// 2. Получение метаданных для формирования имени файла
pl, err := h.service.GetByID(uint(id))
// 3. Установка HTTP-заголовков для скачивания
filename := fmt.Sprintf("pricelist_%s.csv", pl.Version)
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
// 4. UTF-8 BOM для Excel-совместимости
c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})
// 5. Настройка CSV writer
writer := csv.NewWriter(c.Writer)
writer.Comma = ';' // Точка с запятой для Excel
defer writer.Flush()
// 6. Динамические заголовки (зависят от типа данных)
isWarehouse := strings.ToLower(pl.Source) == "warehouse"
var header []string
if isWarehouse {
header = []string{"Артикул", "Категория", "Описание", "Доступно", "Partnumbers", "Цена, $", "Настройки"}
} else {
header = []string{"Артикул", "Категория", "Описание", "Цена, $", "Настройки"}
}
writer.Write(header)
// 7. Streaming в batches через callback
err = h.service.StreamItemsForExport(uint(id), 500, func(items []models.PricelistItem) error {
for _, item := range items {
row := buildRow(item, isWarehouse)
if err := writer.Write(row); err != nil {
return err
}
}
writer.Flush() // Flush после каждого batch
return nil
})
}
```
### 2. Service Layer
**Задачи**: Оркестрация, делегирование в репозиторий
```go
func (s *Service) StreamItemsForExport(pricelistID uint, batchSize int, callback func(items []models.PricelistItem) error) error {
if s.repo == nil {
return fmt.Errorf("offline mode: cannot stream pricelist items")
}
return s.repo.StreamItemsForExport(pricelistID, batchSize, callback)
}
```
### 3. Repository Layer (Критичный)
**Задачи**: Batch-загрузка из БД, оптимизация запросов, enrichment
```go
func (r *PricelistRepository) StreamItemsForExport(pricelistID uint, batchSize int, callback func(items []models.PricelistItem) error) error {
if batchSize <= 0 {
batchSize = 500 // Default batch size
}
// Проверка типа pricelist для conditional enrichment
var pl models.Pricelist
isWarehouse := false
if err := r.db.Select("source").Where("id = ?", pricelistID).First(&pl).Error; err == nil {
isWarehouse = pl.Source == string(models.PricelistSourceWarehouse)
}
offset := 0
for {
var items []models.PricelistItem
// ⚡ КЛЮЧЕВОЙ МОМЕНТ: JOIN для избежания N+1 запросов
err := r.db.Table("qt_pricelist_items AS pi").
Select("pi.*, COALESCE(l.lot_description, '') AS lot_description, COALESCE(l.lot_category, '') AS category").
Joins("LEFT JOIN lot AS l ON l.lot_name = pi.lot_name").
Where("pi.pricelist_id = ?", pricelistID).
Order("pi.lot_name").
Offset(offset).
Limit(batchSize).
Scan(&items).Error
if err != nil || len(items) == 0 {
break
}
// Conditional enrichment для warehouse данных
if isWarehouse {
r.enrichWarehouseItems(items) // Добавление qty, partnumbers
}
// Вызов callback для обработки batch
if err := callback(items); err != nil {
return err
}
if len(items) < batchSize {
break // Последний batch
}
offset += batchSize
}
return nil
}
```
## Ключевые паттерны
### 1. Streaming (не загружать все в память)
```go
// ❌ НЕ ТАК:
var allItems []Item
db.Find(&allItems) // Может упасть на миллионах записей
// ✅ ТАК:
for offset := 0; ; offset += batchSize {
var batch []Item
db.Offset(offset).Limit(batchSize).Find(&batch)
processBatch(batch)
if len(batch) < batchSize {
break
}
}
```
### 2. Callback Pattern для гибкости
```go
// Service не знает о CSV - может использоваться для любого экспорта
func StreamItems(callback func([]Item) error) error
```
### 3. JOIN для избежания N+1
```go
// ❌ N+1 problem:
items := getItems()
for _, item := range items {
description := getLotDescription(item.LotName) // N запросов
}
// ✅ JOIN:
db.Table("items AS i").
Select("i.*, COALESCE(l.description, '') AS description").
Joins("LEFT JOIN lots AS l ON l.name = i.lot_name")
```
### 4. UTF-8 BOM для Excel
```go
// Excel на Windows требует BOM для корректного отображения UTF-8
c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})
```
### 5. Точка с запятой для Excel
```go
writer := csv.NewWriter(c.Writer)
writer.Comma = ';' // Excel в русской локали использует ;
```
### 6. Graceful Error Handling
```go
// После начала streaming нельзя вернуть JSON
if err != nil {
// Уже начали писать CSV, поэтому пишем текст
c.String(http.StatusInternalServerError, "Export failed: %v", err)
return
}
```
## Conditional Enrichment Pattern
```go
// Для warehouse прайслистов добавляем дополнительные поля
func (r *PricelistRepository) enrichWarehouseItems(items []models.PricelistItem) error {
// 1. Собрать уникальные lot_names
lots := make([]string, 0, len(items))
seen := make(map[string]struct{})
for _, item := range items {
if _, ok := seen[item.LotName]; !ok {
lots = append(lots, item.LotName)
seen[item.LotName] = struct{}{}
}
}
// 2. Batch-загрузка метрик (qty, partnumbers)
qtyByLot, partnumbersByLot, err := warehouse.LoadLotMetrics(r.db, lots, true)
// 3. Обогащение items
for i := range items {
if qty, ok := qtyByLot[items[i].LotName]; ok {
items[i].AvailableQty = &qty
}
items[i].Partnumbers = partnumbersByLot[items[i].LotName]
}
return nil
}
```
## Virtual Fields Pattern
```go
type PricelistItem struct {
// Stored fields
ID uint `gorm:"primaryKey"`
LotName string `gorm:"size:255"`
Price float64 `gorm:"type:decimal(12,2)"`
// Virtual fields (populated via JOIN or programmatically)
LotDescription string `gorm:"-:migration" json:"lot_description,omitempty"`
Category string `gorm:"-:migration" json:"category,omitempty"`
AvailableQty *float64 `gorm:"-" json:"available_qty,omitempty"`
Partnumbers []string `gorm:"-" json:"partnumbers,omitempty"`
}
```
- `gorm:"-:migration"` - не создавать колонку в БД, но маппить при SELECT
- `gorm:"-"` - полностью игнорировать при БД операциях
## Checklist для CSV Export
- [ ] HTTP заголовки: Content-Type, Content-Disposition
- [ ] UTF-8 BOM для Excel (0xEF, 0xBB, 0xBF)
- [ ] Разделитель (`;` для русской локали Excel)
- [ ] Streaming с batch processing (не загружать всё в память)
- [ ] JOIN для избежания N+1 запросов
- [ ] Flush после каждого batch
- [ ] Graceful error handling (нельзя JSON после начала streaming)
- [ ] Динамические заголовки (если нужно)
- [ ] Conditional enrichment (если данные зависят от типа)
## Когда использовать этот паттерн
**Используй когда:**
- Экспорт больших датасетов (>1000 записей)
- Нужна Excel-совместимость
- Связанные данные из нескольких таблиц
- Conditional логика enrichment
**Не нужен когда:**
- Малые датасеты (<100 записей) - можно загрузить всё сразу
- Экспорт JSON/XML - другие подходы
- Нет связанных данных - можно упростить
## Пример роутинга (Gin)
```go
// В файле роутера
func SetupRoutes(router *gin.Engine, handler *PricelistHandler) {
api := router.Group("/api")
{
pricelists := api.Group("/pricelists")
{
pricelists.GET("/:id/export", handler.ExportCSV)
}
}
}
```
## Импорты
```go
import (
"encoding/csv"
"fmt"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
```
## Performance Notes
1. **Batch Size**: 500-1000 оптимально для большинства случаев
2. **JOIN vs N+1**: JOIN на порядки быстрее при >100 записях
3. **Memory**: Streaming позволяет экспортировать миллионы записей с минимальной памятью
4. **Indexes**: Убедись что есть индексы на JOIN колонках
## Источник
Реализовано в проекте PriceForge:
- Handler: `internal/handlers/pricelist.go:245-346`
- Service: `internal/services/pricelist/service.go:373-379`
- Repository: `internal/repository/pricelist.go:475-533`
- Models: `internal/models/pricelist.go`

273
internal/appstate/backup.go Normal file
View File

@@ -0,0 +1,273 @@
package appstate
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
type backupPeriod struct {
name string
retention int
key func(time.Time) string
date func(time.Time) string
}
var backupPeriods = []backupPeriod{
{
name: "daily",
retention: 7,
key: func(t time.Time) string {
return t.Format("2006-01-02")
},
date: func(t time.Time) string {
return t.Format("2006-01-02")
},
},
{
name: "weekly",
retention: 4,
key: func(t time.Time) string {
y, w := t.ISOWeek()
return fmt.Sprintf("%04d-W%02d", y, w)
},
date: func(t time.Time) string {
return t.Format("2006-01-02")
},
},
{
name: "monthly",
retention: 12,
key: func(t time.Time) string {
return t.Format("2006-01")
},
date: func(t time.Time) string {
return t.Format("2006-01-02")
},
},
{
name: "yearly",
retention: 10,
key: func(t time.Time) string {
return t.Format("2006")
},
date: func(t time.Time) string {
return t.Format("2006-01-02")
},
},
}
const (
envBackupDisable = "QFS_BACKUP_DISABLE"
envBackupDir = "QFS_BACKUP_DIR"
)
var backupNow = time.Now
// EnsureRotatingLocalBackup creates or refreshes daily/weekly/monthly/yearly backups
// for the local database and config. It keeps a limited number per period.
func EnsureRotatingLocalBackup(dbPath, configPath string) ([]string, error) {
if isBackupDisabled() {
return nil, nil
}
if dbPath == "" {
return nil, nil
}
if _, err := os.Stat(dbPath); err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("stat db: %w", err)
}
root := resolveBackupRoot(dbPath)
now := backupNow()
created := make([]string, 0)
for _, period := range backupPeriods {
newFiles, err := ensurePeriodBackup(root, period, now, dbPath, configPath)
if err != nil {
return created, err
}
if len(newFiles) > 0 {
created = append(created, newFiles...)
}
}
return created, nil
}
func resolveBackupRoot(dbPath string) string {
if fromEnv := strings.TrimSpace(os.Getenv(envBackupDir)); fromEnv != "" {
return filepath.Clean(fromEnv)
}
return filepath.Join(filepath.Dir(dbPath), "backups")
}
func isBackupDisabled() bool {
val := strings.ToLower(strings.TrimSpace(os.Getenv(envBackupDisable)))
return val == "1" || val == "true" || val == "yes"
}
func ensurePeriodBackup(root string, period backupPeriod, now time.Time, dbPath, configPath string) ([]string, error) {
key := period.key(now)
periodDir := filepath.Join(root, period.name)
if err := os.MkdirAll(periodDir, 0755); err != nil {
return nil, fmt.Errorf("create %s backup dir: %w", period.name, err)
}
if hasBackupForKey(periodDir, key) {
return nil, nil
}
archiveName := fmt.Sprintf("qfs-backp-%s.zip", period.date(now))
archivePath := filepath.Join(periodDir, archiveName)
if err := createBackupArchive(archivePath, dbPath, configPath); err != nil {
return nil, fmt.Errorf("create %s backup archive: %w", period.name, err)
}
if err := writePeriodMarker(periodDir, key); err != nil {
return []string{archivePath}, err
}
if err := pruneOldBackups(periodDir, period.retention); err != nil {
return []string{archivePath}, err
}
return []string{archivePath}, nil
}
func hasBackupForKey(periodDir, key string) bool {
marker := periodMarker{Key: ""}
data, err := os.ReadFile(periodMarkerPath(periodDir))
if err != nil {
return false
}
if err := json.Unmarshal(data, &marker); err != nil {
return false
}
return marker.Key == key
}
type periodMarker struct {
Key string `json:"key"`
}
func periodMarkerPath(periodDir string) string {
return filepath.Join(periodDir, ".period.json")
}
func writePeriodMarker(periodDir, key string) error {
data, err := json.MarshalIndent(periodMarker{Key: key}, "", " ")
if err != nil {
return err
}
return os.WriteFile(periodMarkerPath(periodDir), data, 0644)
}
func pruneOldBackups(periodDir string, keep int) error {
entries, err := os.ReadDir(periodDir)
if err != nil {
return fmt.Errorf("read backups dir: %w", err)
}
files := make([]os.DirEntry, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
if strings.HasSuffix(entry.Name(), ".zip") {
files = append(files, entry)
}
}
if len(files) <= keep {
return nil
}
sort.Slice(files, func(i, j int) bool {
infoI, errI := files[i].Info()
infoJ, errJ := files[j].Info()
if errI != nil || errJ != nil {
return files[i].Name() < files[j].Name()
}
return infoI.ModTime().Before(infoJ.ModTime())
})
for i := 0; i < len(files)-keep; i++ {
path := filepath.Join(periodDir, files[i].Name())
if err := os.Remove(path); err != nil {
return fmt.Errorf("remove old backup %s: %w", path, err)
}
}
return nil
}
func createBackupArchive(destPath, dbPath, configPath string) error {
file, err := os.Create(destPath)
if err != nil {
return err
}
defer file.Close()
zipWriter := zip.NewWriter(file)
if err := addZipFile(zipWriter, dbPath); err != nil {
_ = zipWriter.Close()
return err
}
_ = addZipOptionalFile(zipWriter, dbPath+"-wal")
_ = addZipOptionalFile(zipWriter, dbPath+"-shm")
if strings.TrimSpace(configPath) != "" {
_ = addZipOptionalFile(zipWriter, configPath)
}
if err := zipWriter.Close(); err != nil {
return err
}
return file.Sync()
}
func addZipOptionalFile(writer *zip.Writer, path string) error {
if _, err := os.Stat(path); err != nil {
return nil
}
return addZipFile(writer, path)
}
func addZipFile(writer *zip.Writer, path string) error {
in, err := os.Open(path)
if err != nil {
return err
}
defer in.Close()
info, err := in.Stat()
if err != nil {
return err
}
header, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
header.Name = filepath.Base(path)
header.Method = zip.Deflate
out, err := writer.CreateHeader(header)
if err != nil {
return err
}
_, err = io.Copy(out, in)
return err
}

View File

@@ -0,0 +1,83 @@
package appstate
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestEnsureRotatingLocalBackupCreatesAndRotates(t *testing.T) {
temp := t.TempDir()
dbPath := filepath.Join(temp, "qfs.db")
cfgPath := filepath.Join(temp, "config.yaml")
if err := os.WriteFile(dbPath, []byte("db"), 0644); err != nil {
t.Fatalf("write db: %v", err)
}
if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil {
t.Fatalf("write config: %v", err)
}
prevNow := backupNow
defer func() { backupNow = prevNow }()
backupNow = func() time.Time { return time.Date(2026, 2, 11, 10, 0, 0, 0, time.UTC) }
created, err := EnsureRotatingLocalBackup(dbPath, cfgPath)
if err != nil {
t.Fatalf("backup: %v", err)
}
if len(created) == 0 {
t.Fatalf("expected backup to be created")
}
dailyArchive := filepath.Join(temp, "backups", "daily", "qfs-backp-2026-02-11.zip")
if _, err := os.Stat(dailyArchive); err != nil {
t.Fatalf("daily archive missing: %v", err)
}
backupNow = func() time.Time { return time.Date(2026, 2, 12, 10, 0, 0, 0, time.UTC) }
created, err = EnsureRotatingLocalBackup(dbPath, cfgPath)
if err != nil {
t.Fatalf("backup rotate: %v", err)
}
if len(created) == 0 {
t.Fatalf("expected backup to be created for new day")
}
dailyArchive = filepath.Join(temp, "backups", "daily", "qfs-backp-2026-02-12.zip")
if _, err := os.Stat(dailyArchive); err != nil {
t.Fatalf("daily archive missing after rotate: %v", err)
}
}
func TestEnsureRotatingLocalBackupEnvControls(t *testing.T) {
temp := t.TempDir()
dbPath := filepath.Join(temp, "qfs.db")
cfgPath := filepath.Join(temp, "config.yaml")
if err := os.WriteFile(dbPath, []byte("db"), 0644); err != nil {
t.Fatalf("write db: %v", err)
}
if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil {
t.Fatalf("write config: %v", err)
}
backupRoot := filepath.Join(temp, "custom_backups")
t.Setenv(envBackupDir, backupRoot)
if _, err := EnsureRotatingLocalBackup(dbPath, cfgPath); err != nil {
t.Fatalf("backup with env: %v", err)
}
if _, err := os.Stat(filepath.Join(backupRoot, "daily", "meta.json")); err != nil {
t.Fatalf("expected backup in custom dir: %v", err)
}
t.Setenv(envBackupDisable, "1")
if _, err := EnsureRotatingLocalBackup(dbPath, cfgPath); err != nil {
t.Fatalf("backup disabled: %v", err)
}
if _, err := os.Stat(filepath.Join(backupRoot, "daily", "meta.json")); err != nil {
t.Fatalf("backup should remain from previous run: %v", err)
}
}

View File

@@ -0,0 +1,124 @@
package article
import (
"errors"
"fmt"
"strings"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
)
// ErrMissingCategoryForLot is returned when a lot has no category in local_pricelist_items.lot_category.
var ErrMissingCategoryForLot = errors.New("missing_category_for_lot")
type MissingCategoryForLotError struct {
LotName string
}
func (e *MissingCategoryForLotError) Error() string {
if e == nil || strings.TrimSpace(e.LotName) == "" {
return ErrMissingCategoryForLot.Error()
}
return fmt.Sprintf("%s: %s", ErrMissingCategoryForLot.Error(), e.LotName)
}
func (e *MissingCategoryForLotError) Unwrap() error {
return ErrMissingCategoryForLot
}
type Group string
const (
GroupCPU Group = "CPU"
GroupMEM Group = "MEM"
GroupGPU Group = "GPU"
GroupDISK Group = "DISK"
GroupNET Group = "NET"
GroupPSU Group = "PSU"
)
// GroupForLotCategory maps pricelist lot_category codes into article groups.
// Unknown/unrelated categories return ok=false.
func GroupForLotCategory(cat string) (group Group, ok bool) {
c := strings.ToUpper(strings.TrimSpace(cat))
switch c {
case "CPU":
return GroupCPU, true
case "MEM":
return GroupMEM, true
case "GPU":
return GroupGPU, true
case "M2", "SSD", "HDD", "EDSFF", "HHHL":
return GroupDISK, true
case "NIC", "HCA", "DPU":
return GroupNET, true
case "HBA":
return GroupNET, true
case "PSU", "PS":
return GroupPSU, true
default:
return "", false
}
}
// ResolveLotCategoriesStrict resolves categories for lotNames using local_pricelist_items.lot_category
// for a given server pricelist id. If any lot is missing or has empty category, returns an error.
func ResolveLotCategoriesStrict(local *localdb.LocalDB, serverPricelistID uint, lotNames []string) (map[string]string, error) {
if local == nil {
return nil, fmt.Errorf("local db is nil")
}
cats, err := local.GetLocalLotCategoriesByServerPricelistID(serverPricelistID, lotNames)
if err != nil {
return nil, err
}
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}
}
}
}
return cats, nil
}
// NormalizeServerModel produces a stable article segment for the server model.
func NormalizeServerModel(model string) string {
trimmed := strings.TrimSpace(model)
if trimmed == "" {
return ""
}
upper := strings.ToUpper(trimmed)
var b strings.Builder
for _, r := range upper {
if r >= 'A' && r <= 'Z' {
b.WriteRune(r)
continue
}
if r >= '0' && r <= '9' {
b.WriteRune(r)
continue
}
if r == '.' {
b.WriteRune(r)
}
}
return b.String()
}

View File

@@ -0,0 +1,98 @@
package article
import (
"errors"
"path/filepath"
"testing"
"time"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
)
func TestResolveLotCategoriesStrict_MissingCategoryReturnsError(t *testing.T) {
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
if err != nil {
t.Fatalf("init local db: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
ServerID: 1,
Source: "estimate",
Version: "S-2026-02-11-001",
Name: "test",
CreatedAt: time.Now(),
SyncedAt: time.Now(),
}); err != nil {
t.Fatalf("save local pricelist: %v", err)
}
localPL, err := local.GetLocalPricelistByServerID(1)
if err != nil {
t.Fatalf("get local pricelist: %v", err)
}
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
{PricelistID: localPL.ID, LotName: "CPU_A", LotCategory: "", Price: 10},
}); err != nil {
t.Fatalf("save local items: %v", err)
}
_, err = ResolveLotCategoriesStrict(local, 1, []string{"CPU_A"})
if err == nil {
t.Fatalf("expected error")
}
if !errors.Is(err, ErrMissingCategoryForLot) {
t.Fatalf("expected ErrMissingCategoryForLot, got %v", err)
}
}
func TestResolveLotCategoriesStrict_FallbackToLocalComponents(t *testing.T) {
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
if err != nil {
t.Fatalf("init local db: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
ServerID: 2,
Source: "estimate",
Version: "S-2026-02-11-002",
Name: "test",
CreatedAt: time.Now(),
SyncedAt: time.Now(),
}); err != nil {
t.Fatalf("save local pricelist: %v", err)
}
localPL, err := local.GetLocalPricelistByServerID(2)
if err != nil {
t.Fatalf("get local pricelist: %v", err)
}
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
{PricelistID: localPL.ID, LotName: "CPU_B", LotCategory: "", Price: 10},
}); err != nil {
t.Fatalf("save local items: %v", err)
}
if err := local.DB().Create(&localdb.LocalComponent{
LotName: "CPU_B",
Category: "CPU",
LotDescription: "cpu",
}).Error; err != nil {
t.Fatalf("save local components: %v", err)
}
cats, err := ResolveLotCategoriesStrict(local, 2, []string{"CPU_B"})
if err != nil {
t.Fatalf("expected fallback, got error: %v", err)
}
if cats["CPU_B"] != "CPU" {
t.Fatalf("expected CPU, got %q", cats["CPU_B"])
}
}
func TestGroupForLotCategory(t *testing.T) {
if g, ok := GroupForLotCategory("cpu"); !ok || g != GroupCPU {
t.Fatalf("expected cpu -> GroupCPU")
}
if g, ok := GroupForLotCategory("SFP"); ok || g != "" {
t.Fatalf("expected SFP to be excluded")
}
}

View File

@@ -0,0 +1,602 @@
package article
import (
"fmt"
"regexp"
"sort"
"strings"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
)
type BuildOptions struct {
ServerModel string
SupportCode string
ServerPricelist *uint
}
type BuildResult struct {
Article string
Warnings []string
}
var (
reMemGiB = regexp.MustCompile(`(?i)(\d+)\s*(GB|G)`)
reMemTiB = regexp.MustCompile(`(?i)(\d+)\s*(TB|T)`)
reCapacityT = regexp.MustCompile(`(?i)(\d+(?:[.,]\d+)?)T`)
reCapacityG = regexp.MustCompile(`(?i)(\d+(?:[.,]\d+)?)G`)
rePortSpeed = regexp.MustCompile(`(?i)(\d+)p(\d+)(GbE|G)`)
rePortFC = regexp.MustCompile(`(?i)(\d+)pFC(\d+)`)
reWatts = regexp.MustCompile(`(?i)(\d{3,5})\s*W`)
)
func Build(local *localdb.LocalDB, items []models.ConfigItem, opts BuildOptions) (BuildResult, error) {
segments := make([]string, 0, 8)
warnings := make([]string, 0)
model := NormalizeServerModel(opts.ServerModel)
if model == "" {
return BuildResult{}, fmt.Errorf("server_model required")
}
segments = append(segments, model)
lotNames := make([]string, 0, len(items))
for _, it := range items {
lotNames = append(lotNames, it.LotName)
}
if opts.ServerPricelist == nil || *opts.ServerPricelist == 0 {
return BuildResult{}, fmt.Errorf("pricelist_id required for article")
}
cats, err := ResolveLotCategoriesStrict(local, *opts.ServerPricelist, lotNames)
if err != nil {
return BuildResult{}, err
}
cpuSeg := buildCPUSegment(items, cats)
if cpuSeg != "" {
segments = append(segments, cpuSeg)
}
memSeg, memWarn := buildMemSegment(items, cats)
if memWarn != "" {
warnings = append(warnings, memWarn)
}
if memSeg != "" {
segments = append(segments, memSeg)
}
gpuSeg := buildGPUSegment(items, cats)
if gpuSeg != "" {
segments = append(segments, gpuSeg)
}
diskSeg, diskWarn := buildDiskSegment(items, cats)
if diskWarn != "" {
warnings = append(warnings, diskWarn)
}
if diskSeg != "" {
segments = append(segments, diskSeg)
}
netSeg, netWarn := buildNetSegment(items, cats)
if netWarn != "" {
warnings = append(warnings, netWarn)
}
if netSeg != "" {
segments = append(segments, netSeg)
}
psuSeg, psuWarn := buildPSUSegment(items, cats)
if psuWarn != "" {
warnings = append(warnings, psuWarn)
}
if psuSeg != "" {
segments = append(segments, psuSeg)
}
if strings.TrimSpace(opts.SupportCode) != "" {
code := strings.TrimSpace(opts.SupportCode)
if !isSupportCodeValid(code) {
return BuildResult{}, fmt.Errorf("invalid_support_code")
}
segments = append(segments, code)
}
article := strings.Join(segments, "-")
if len([]rune(article)) > 80 {
article = compressArticle(segments)
warnings = append(warnings, "compressed")
}
if len([]rune(article)) > 80 {
return BuildResult{}, fmt.Errorf("article_overflow")
}
return BuildResult{Article: article, Warnings: warnings}, nil
}
func isSupportCodeValid(code string) bool {
if len(code) < 3 {
return false
}
if !strings.Contains(code, "y") {
return false
}
parts := strings.Split(code, "y")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return false
}
for _, r := range parts[0] {
if r < '0' || r > '9' {
return false
}
}
switch parts[1] {
case "W", "B", "S", "P":
return true
default:
return false
}
}
func buildCPUSegment(items []models.ConfigItem, cats map[string]string) string {
type agg struct {
qty int
}
models := map[string]*agg{}
for _, it := range items {
group, ok := GroupForLotCategory(cats[it.LotName])
if !ok || group != GroupCPU {
continue
}
model := parseCPUModel(it.LotName)
if model == "" {
model = "UNK"
}
if _, ok := models[model]; !ok {
models[model] = &agg{}
}
models[model].qty += it.Quantity
}
if len(models) == 0 {
return ""
}
parts := make([]string, 0, len(models))
for model, a := range models {
parts = append(parts, fmt.Sprintf("%dx%s", a.qty, model))
}
sort.Strings(parts)
return strings.Join(parts, "+")
}
func buildMemSegment(items []models.ConfigItem, cats map[string]string) (string, string) {
totalGiB := 0
for _, it := range items {
group, ok := GroupForLotCategory(cats[it.LotName])
if !ok || group != GroupMEM {
continue
}
per := parseMemGiB(it.LotName)
if per <= 0 {
return "", "mem_unknown"
}
totalGiB += per * it.Quantity
}
if totalGiB == 0 {
return "", ""
}
if totalGiB%1024 == 0 {
return fmt.Sprintf("%dT", totalGiB/1024), ""
}
return fmt.Sprintf("%dG", totalGiB), ""
}
func buildGPUSegment(items []models.ConfigItem, cats map[string]string) string {
models := map[string]int{}
for _, it := range items {
group, ok := GroupForLotCategory(cats[it.LotName])
if !ok || group != GroupGPU {
continue
}
model := parseGPUModel(it.LotName)
if model == "" {
model = "UNK"
}
models[model] += it.Quantity
}
if len(models) == 0 {
return ""
}
parts := make([]string, 0, len(models))
for model, qty := range models {
parts = append(parts, fmt.Sprintf("%dx%s", qty, model))
}
sort.Strings(parts)
return strings.Join(parts, "+")
}
func buildDiskSegment(items []models.ConfigItem, cats map[string]string) (string, string) {
type key struct {
t string
c string
}
groupQty := map[key]int{}
warn := ""
for _, it := range items {
group, ok := GroupForLotCategory(cats[it.LotName])
if !ok || group != GroupDISK {
continue
}
capToken := parseCapacity(it.LotName)
if capToken == "" {
warn = "disk_unknown"
}
typeCode := diskTypeCode(cats[it.LotName], it.LotName)
k := key{t: typeCode, c: capToken}
groupQty[k] += it.Quantity
}
if len(groupQty) == 0 {
return "", ""
}
parts := make([]string, 0, len(groupQty))
for k, qty := range groupQty {
if k.c == "" {
parts = append(parts, fmt.Sprintf("%dx%s", qty, k.t))
} else {
parts = append(parts, fmt.Sprintf("%dx%s%s", qty, k.c, k.t))
}
}
sort.Strings(parts)
return strings.Join(parts, "+"), warn
}
func buildNetSegment(items []models.ConfigItem, cats map[string]string) (string, string) {
groupQty := map[string]int{}
warn := ""
for _, it := range items {
group, ok := GroupForLotCategory(cats[it.LotName])
if !ok || group != GroupNET {
continue
}
profile := parsePortSpeed(it.LotName)
if profile == "" {
profile = "UNKNET"
warn = "net_unknown"
}
groupQty[profile] += it.Quantity
}
if len(groupQty) == 0 {
return "", ""
}
parts := make([]string, 0, len(groupQty))
for profile, qty := range groupQty {
parts = append(parts, fmt.Sprintf("%dx%s", qty, profile))
}
sort.Strings(parts)
return strings.Join(parts, "+"), warn
}
func buildPSUSegment(items []models.ConfigItem, cats map[string]string) (string, string) {
groupQty := map[string]int{}
warn := ""
for _, it := range items {
group, ok := GroupForLotCategory(cats[it.LotName])
if !ok || group != GroupPSU {
continue
}
rating := parseWatts(it.LotName)
if rating == "" {
rating = "UNKPSU"
warn = "psu_unknown"
}
groupQty[rating] += it.Quantity
}
if len(groupQty) == 0 {
return "", ""
}
parts := make([]string, 0, len(groupQty))
for rating, qty := range groupQty {
parts = append(parts, fmt.Sprintf("%dx%s", qty, rating))
}
sort.Strings(parts)
return strings.Join(parts, "+"), warn
}
func normalizeModelToken(lotName string) string {
if idx := strings.Index(lotName, "_"); idx >= 0 && idx+1 < len(lotName) {
lotName = lotName[idx+1:]
}
parts := strings.Split(lotName, "_")
token := parts[len(parts)-1]
return strings.ToUpper(strings.TrimSpace(token))
}
func parseCPUModel(lotName string) string {
parts := strings.Split(lotName, "_")
if len(parts) >= 2 {
last := strings.ToUpper(strings.TrimSpace(parts[len(parts)-1]))
if last != "" {
return last
}
}
return normalizeModelToken(lotName)
}
func parseGPUModel(lotName string) string {
upper := strings.ToUpper(lotName)
if idx := strings.Index(upper, "GPU_"); idx >= 0 {
upper = upper[idx+4:]
}
parts := strings.Split(upper, "_")
model := ""
mem := ""
for i, p := range parts {
if p == "" {
continue
}
switch p {
case "NV", "NVIDIA", "AMD", "RADEON", "PCIE", "PCI", "SXM", "SXMX":
continue
default:
if strings.Contains(p, "GB") {
mem = p
continue
}
if model == "" && (i > 0) {
model = p
}
}
}
if model != "" && mem != "" {
return model + "_" + mem
}
if model != "" {
return model
}
return normalizeModelToken(lotName)
}
func parseMemGiB(lotName string) int {
if m := reMemTiB.FindStringSubmatch(lotName); len(m) == 3 {
return atoi(m[1]) * 1024
}
if m := reMemGiB.FindStringSubmatch(lotName); len(m) == 3 {
return atoi(m[1])
}
return 0
}
func parseCapacity(lotName string) string {
if m := reCapacityT.FindStringSubmatch(lotName); len(m) == 2 {
return normalizeTToken(strings.ReplaceAll(m[1], ",", ".")) + "T"
}
if m := reCapacityG.FindStringSubmatch(lotName); len(m) == 2 {
return normalizeNumberToken(strings.ReplaceAll(m[1], ",", ".")) + "G"
}
return ""
}
func diskTypeCode(cat string, lotName string) string {
c := strings.ToUpper(strings.TrimSpace(cat))
if c == "M2" {
return "M2"
}
upper := strings.ToUpper(lotName)
if strings.Contains(upper, "NVME") {
return "NV"
}
if strings.Contains(upper, "SAS") {
return "SAS"
}
if strings.Contains(upper, "SATA") {
return "SAT"
}
return c
}
func parsePortSpeed(lotName string) string {
if m := rePortSpeed.FindStringSubmatch(lotName); len(m) == 4 {
return fmt.Sprintf("%sp%sG", m[1], m[2])
}
if m := rePortFC.FindStringSubmatch(lotName); len(m) == 3 {
return fmt.Sprintf("%spFC%s", m[1], m[2])
}
return ""
}
func parseWatts(lotName string) string {
if m := reWatts.FindStringSubmatch(lotName); len(m) == 2 {
w := atoi(m[1])
if w >= 1000 {
kw := fmt.Sprintf("%.1f", float64(w)/1000.0)
kw = strings.TrimSuffix(kw, ".0")
return fmt.Sprintf("%skW", kw)
}
return fmt.Sprintf("%dW", w)
}
return ""
}
func normalizeNumberToken(raw string) string {
raw = strings.TrimSpace(raw)
raw = strings.TrimLeft(raw, "0")
if raw == "" || raw[0] == '.' {
raw = "0" + raw
}
return raw
}
func normalizeTToken(raw string) string {
raw = normalizeNumberToken(raw)
parts := strings.SplitN(raw, ".", 2)
intPart := parts[0]
frac := ""
if len(parts) == 2 {
frac = parts[1]
}
if frac == "" {
frac = "0"
}
if len(intPart) >= 2 {
return intPart + "." + frac
}
if len(frac) > 1 {
frac = frac[:1]
}
return intPart + "." + frac
}
func atoi(v string) int {
n := 0
for _, r := range v {
if r < '0' || r > '9' {
continue
}
n = n*10 + int(r-'0')
}
return n
}
func compressArticle(segments []string) string {
if len(segments) == 0 {
return ""
}
normalized := make([]string, 0, len(segments))
for _, s := range segments {
normalized = append(normalized, strings.ReplaceAll(s, "GbE", "G"))
}
segments = normalized
article := strings.Join(segments, "-")
if len([]rune(article)) <= 80 {
return article
}
// segment order: model, cpu, mem, gpu, disk, net, psu, support
index := func(i int) (int, bool) {
if i >= 0 && i < len(segments) {
return i, true
}
return -1, false
}
// 1) remove PSU
if i, ok := index(6); ok {
segments = append(segments[:i], segments[i+1:]...)
article = strings.Join(segments, "-")
if len([]rune(article)) <= 80 {
return article
}
}
// 2) compress NET/HBA/HCA
if i, ok := index(5); ok {
segments[i] = compressNetSegment(segments[i])
article = strings.Join(segments, "-")
if len([]rune(article)) <= 80 {
return article
}
}
// 3) compress DISK
if i, ok := index(4); ok {
segments[i] = compressDiskSegment(segments[i])
article = strings.Join(segments, "-")
if len([]rune(article)) <= 80 {
return article
}
}
// 4) compress GPU to vendor only (GPU_NV)
if i, ok := index(3); ok {
segments[i] = compressGPUSegment(segments[i])
}
return strings.Join(segments, "-")
}
func compressNetSegment(seg string) string {
if seg == "" {
return seg
}
parts := strings.Split(seg, "+")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
qty := "1"
profile := p
if x := strings.SplitN(p, "x", 2); len(x) == 2 {
qty = x[0]
profile = x[1]
}
upper := strings.ToUpper(profile)
label := "NIC"
if strings.Contains(upper, "FC") {
label = "HBA"
} else if strings.Contains(upper, "HCA") || strings.Contains(upper, "IB") {
label = "HCA"
}
out = append(out, fmt.Sprintf("%sx%s", qty, label))
}
if len(out) == 0 {
return seg
}
sort.Strings(out)
return strings.Join(out, "+")
}
func compressDiskSegment(seg string) string {
if seg == "" {
return seg
}
parts := strings.Split(seg, "+")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
qty := "1"
spec := p
if x := strings.SplitN(p, "x", 2); len(x) == 2 {
qty = x[0]
spec = x[1]
}
upper := strings.ToUpper(spec)
label := "DSK"
for _, t := range []string{"M2", "NV", "SAS", "SAT", "SSD", "HDD", "EDS", "HHH"} {
if strings.Contains(upper, t) {
label = t
break
}
}
out = append(out, fmt.Sprintf("%sx%s", qty, label))
}
if len(out) == 0 {
return seg
}
sort.Strings(out)
return strings.Join(out, "+")
}
func compressGPUSegment(seg string) string {
if seg == "" {
return seg
}
parts := strings.Split(seg, "+")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
qty := "1"
if x := strings.SplitN(p, "x", 2); len(x) == 2 {
qty = x[0]
}
out = append(out, fmt.Sprintf("%sxGPU_NV", qty))
}
if len(out) == 0 {
return seg
}
sort.Strings(out)
return strings.Join(out, "+")
}

View File

@@ -0,0 +1,66 @@
package article
import (
"path/filepath"
"strings"
"testing"
"time"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
)
func TestBuild_ParsesNetAndPSU(t *testing.T) {
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
if err != nil {
t.Fatalf("init local db: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
ServerID: 1,
Source: "estimate",
Version: "S-2026-02-11-001",
Name: "test",
CreatedAt: time.Now(),
SyncedAt: time.Now(),
}); err != nil {
t.Fatalf("save local pricelist: %v", err)
}
localPL, err := local.GetLocalPricelistByServerID(1)
if err != nil {
t.Fatalf("get local pricelist: %v", err)
}
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
{PricelistID: localPL.ID, LotName: "NIC_2p25G_MCX512A-AC", LotCategory: "NIC", Price: 1},
{PricelistID: localPL.ID, LotName: "HBA_2pFC32_Gen6", LotCategory: "HBA", Price: 1},
{PricelistID: localPL.ID, LotName: "PS_1000W_Platinum", LotCategory: "PS", Price: 1},
}); err != nil {
t.Fatalf("save local items: %v", err)
}
items := models.ConfigItems{
{LotName: "NIC_2p25G_MCX512A-AC", Quantity: 1},
{LotName: "HBA_2pFC32_Gen6", Quantity: 1},
{LotName: "PS_1000W_Platinum", Quantity: 2},
}
result, err := Build(local, items, BuildOptions{
ServerModel: "DL380GEN11",
SupportCode: "1yW",
ServerPricelist: &localPL.ServerID,
})
if err != nil {
t.Fatalf("build article: %v", err)
}
if result.Article == "" {
t.Fatalf("expected article to be non-empty")
}
if contains(result.Article, "UNKNET") || contains(result.Article, "UNKPSU") {
t.Fatalf("unexpected UNK in article: %s", result.Article)
}
}
func contains(s, sub string) bool {
return strings.Contains(s, sub)
}

View File

@@ -20,6 +20,7 @@ type Config struct {
Alerts AlertsConfig `yaml:"alerts"`
Notifications NotificationsConfig `yaml:"notifications"`
Logging LoggingConfig `yaml:"logging"`
Backup BackupConfig `yaml:"backup"`
}
type ServerConfig struct {
@@ -101,6 +102,10 @@ type LoggingConfig struct {
FilePath string `yaml:"file_path"`
}
type BackupConfig struct {
Time string `yaml:"time"`
}
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
@@ -182,6 +187,10 @@ func (c *Config) setDefaults() {
if c.Logging.Output == "" {
c.Logging.Output = "stdout"
}
if c.Backup.Time == "" {
c.Backup.Time = "00:00"
}
}
func (c *Config) Address() string {

View File

@@ -3,6 +3,7 @@ package handlers
import (
"fmt"
"net/http"
"strings"
"time"
"git.mchus.pro/mchus/quoteforge/internal/middleware"
@@ -35,6 +36,7 @@ type ExportRequest struct {
Name string `json:"name" binding:"required"`
ProjectName string `json:"project_name"`
ProjectUUID string `json:"project_uuid"`
Article string `json:"article"`
Items []struct {
LotName string `json:"lot_name" binding:"required"`
Quantity int `json:"quantity" binding:"required,min=1"`
@@ -64,7 +66,7 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
// Try to load project name from database
username := middleware.GetUsername(c)
if project, err := h.projectService.GetByUUID(req.ProjectUUID, username); err == nil && project != nil {
projectName = project.Name
projectName = derefString(project.Name)
}
}
if projectName == "" {
@@ -73,7 +75,11 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
// Set headers before streaming
exportDate := data.CreatedAt
filename := fmt.Sprintf("%s (%s) %s BOM.csv", exportDate.Format("2006-01-02"), projectName, req.Name)
articleSegment := sanitizeFilenameSegment(req.Article)
if articleSegment == "" {
articleSegment = "BOM"
}
filename := fmt.Sprintf("%s (%s) %s %s.csv", exportDate.Format("2006-01-02"), projectName, req.Name, articleSegment)
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
@@ -84,6 +90,13 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
}
}
func derefString(value *string) string {
if value == nil {
return ""
}
return *value
}
func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData {
items := make([]services.ExportItem, len(req.Items))
var total float64
@@ -116,6 +129,7 @@ func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData
return &services.ExportData{
Name: req.Name,
Article: req.Article,
Items: items,
Total: total,
Notes: req.Notes,
@@ -123,6 +137,24 @@ func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData
}
}
func sanitizeFilenameSegment(value string) string {
if strings.TrimSpace(value) == "" {
return ""
}
replacer := strings.NewReplacer(
"/", "_",
"\\", "_",
":", "_",
"*", "_",
"?", "_",
"\"", "_",
"<", "_",
">", "_",
"|", "_",
)
return strings.TrimSpace(replacer.Replace(value))
}
func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
username := middleware.GetUsername(c)
uuid := c.Param("uuid")
@@ -146,7 +178,7 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
projectName := config.Name // fallback: use config name if no project
if config.ProjectUUID != nil && *config.ProjectUUID != "" {
if project, err := h.projectService.GetByUUID(*config.ProjectUUID, username); err == nil && project != nil {
projectName = project.Name
projectName = derefString(project.Name)
}
}

View File

@@ -157,15 +157,11 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
}
resultItems := make([]gin.H, 0, len(items))
for _, item := range items {
category := ""
if parts := strings.SplitN(item.LotName, "_", 2); len(parts) > 0 {
category = parts[0]
}
resultItems = append(resultItems, gin.H{
"id": item.ID,
"lot_name": item.LotName,
"price": item.Price,
"category": category,
"category": item.LotCategory,
"available_qty": item.AvailableQty,
"partnumbers": []string(item.Partnumbers),
})

View File

@@ -0,0 +1,84 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"github.com/gin-gonic/gin"
)
func TestPricelistGetItems_ReturnsLotCategoryFromLocalPricelistItems(t *testing.T) {
gin.SetMode(gin.TestMode)
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
if err != nil {
t.Fatalf("init local db: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
ServerID: 1,
Source: "estimate",
Version: "S-2026-02-11-001",
Name: "test",
CreatedAt: time.Now(),
SyncedAt: time.Now(),
IsUsed: false,
}); err != nil {
t.Fatalf("save local pricelist: %v", err)
}
localPL, err := local.GetLocalPricelistByServerID(1)
if err != nil {
t.Fatalf("get local pricelist: %v", err)
}
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
{
PricelistID: localPL.ID,
LotName: "NO_UNDERSCORE_NAME",
LotCategory: "CPU",
Price: 10,
},
}); err != nil {
t.Fatalf("save local pricelist items: %v", err)
}
h := NewPricelistHandler(local)
req, _ := http.NewRequest("GET", "/api/pricelists/1/items?page=1&per_page=50", nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = req
c.Params = gin.Params{{Key: "id", Value: "1"}}
h.GetItems(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
Items []struct {
LotName string `json:"lot_name"`
Category string `json:"category"`
UnitPrice any `json:"price"`
} `json:"items"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if len(resp.Items) != 1 {
t.Fatalf("expected 1 item, got %d", len(resp.Items))
}
if resp.Items[0].LotName != "NO_UNDERSCORE_NAME" {
t.Fatalf("expected lot_name NO_UNDERSCORE_NAME, got %q", resp.Items[0].LotName)
}
if resp.Items[0].Category != "CPU" {
t.Fatalf("expected category CPU, got %q", resp.Items[0].Category)
}
}

View File

@@ -147,8 +147,8 @@ func (h *WebHandler) render(c *gin.Context, name string, data gin.H) {
}
func (h *WebHandler) Index(c *gin.Context) {
// Redirect to configs page - configurator is accessed via /configurator?uuid=...
c.Redirect(302, "/configs")
// Redirect to projects page - configurator is accessed via /configurator?uuid=...
c.Redirect(302, "/projects")
}
func (h *WebHandler) Configurator(c *gin.Context) {

View File

@@ -242,6 +242,31 @@ func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) {
return &component, nil
}
// GetLocalComponentCategoriesByLotNames returns category for each lot_name in the local component cache.
// Missing lots are not included in the map; caller is responsible for strict validation.
func (l *LocalDB) GetLocalComponentCategoriesByLotNames(lotNames []string) (map[string]string, error) {
result := make(map[string]string, len(lotNames))
if len(lotNames) == 0 {
return result, nil
}
type row struct {
LotName string `gorm:"column:lot_name"`
Category string `gorm:"column:category"`
}
var rows []row
if err := l.db.Model(&LocalComponent{}).
Select("lot_name, category").
Where("lot_name IN ?", lotNames).
Find(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
result[r.LotName] = r.Category
}
return result, nil
}
// GetLocalComponentCategories returns distinct categories from local components
func (l *LocalDB) GetLocalComponentCategories() ([]string, error) {
var categories []string
@@ -302,4 +327,3 @@ func (l *LocalDB) NeedComponentSync(maxAgeHours int) bool {
}
return time.Since(*syncTime).Hours() > float64(maxAgeHours)
}

View File

@@ -28,6 +28,9 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
Notes: cfg.Notes,
IsTemplate: cfg.IsTemplate,
ServerCount: cfg.ServerCount,
ServerModel: cfg.ServerModel,
SupportCode: cfg.SupportCode,
Article: cfg.Article,
PricelistID: cfg.PricelistID,
OnlyInStock: cfg.OnlyInStock,
PriceUpdatedAt: cfg.PriceUpdatedAt,
@@ -72,6 +75,9 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
Notes: local.Notes,
IsTemplate: local.IsTemplate,
ServerCount: local.ServerCount,
ServerModel: local.ServerModel,
SupportCode: local.SupportCode,
Article: local.Article,
PricelistID: local.PricelistID,
OnlyInStock: local.OnlyInStock,
PriceUpdatedAt: local.PriceUpdatedAt,
@@ -100,6 +106,8 @@ func ProjectToLocal(project *models.Project) *LocalProject {
local := &LocalProject{
UUID: project.UUID,
OwnerUsername: project.OwnerUsername,
Code: project.Code,
Variant: project.Variant,
Name: project.Name,
TrackerURL: project.TrackerURL,
IsActive: project.IsActive,
@@ -119,6 +127,8 @@ func LocalToProject(local *LocalProject) *models.Project {
project := &models.Project{
UUID: local.UUID,
OwnerUsername: local.OwnerUsername,
Code: local.Code,
Variant: local.Variant,
Name: local.Name,
TrackerURL: local.TrackerURL,
IsActive: local.IsActive,
@@ -169,6 +179,7 @@ func PricelistItemToLocal(item *models.PricelistItem, localPricelistID uint) *Lo
return &LocalPricelistItem{
PricelistID: localPricelistID,
LotName: item.LotName,
LotCategory: item.LotCategory,
Price: item.Price,
AvailableQty: item.AvailableQty,
Partnumbers: partnumbers,
@@ -183,6 +194,7 @@ func LocalToPricelistItem(local *LocalPricelistItem, serverPricelistID uint) *mo
ID: local.ID,
PricelistID: serverPricelistID,
LotName: local.LotName,
LotCategory: local.LotCategory,
Price: local.Price,
AvailableQty: local.AvailableQty,
Partnumbers: partnumbers,

View File

@@ -0,0 +1,34 @@
package localdb
import (
"testing"
"git.mchus.pro/mchus/quoteforge/internal/models"
)
func TestPricelistItemToLocal_PreservesLotCategory(t *testing.T) {
item := &models.PricelistItem{
LotName: "CPU_A",
LotCategory: "CPU",
Price: 10,
}
local := PricelistItemToLocal(item, 123)
if local.LotCategory != "CPU" {
t.Fatalf("expected LotCategory=CPU, got %q", local.LotCategory)
}
}
func TestLocalToPricelistItem_PreservesLotCategory(t *testing.T) {
local := &LocalPricelistItem{
LotName: "CPU_A",
LotCategory: "CPU",
Price: 10,
}
item := LocalToPricelistItem(local, 456)
if item.LotCategory != "CPU" {
t.Fatalf("expected LotCategory=CPU, got %q", item.LotCategory)
}
}

View File

@@ -12,6 +12,7 @@ import (
"time"
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
"git.mchus.pro/mchus/quoteforge/internal/appstate"
"github.com/glebarez/sqlite"
mysqlDriver "github.com/go-sql-driver/mysql"
uuidpkg "github.com/google/uuid"
@@ -41,6 +42,49 @@ type LocalDB struct {
path string
}
// ResetData clears local data tables while keeping connection settings.
// It does not drop schema or connection_settings.
func ResetData(dbPath string) error {
if strings.TrimSpace(dbPath) == "" {
return nil
}
if _, err := os.Stat(dbPath); err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return fmt.Errorf("stat local db: %w", err)
}
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
return fmt.Errorf("opening sqlite database: %w", err)
}
// Order does not matter because we use DELETEs without FK constraints in SQLite.
tables := []string{
"local_projects",
"local_configurations",
"local_configuration_versions",
"local_pricelists",
"local_pricelist_items",
"local_components",
"local_remote_migrations_applied",
"local_sync_guard_state",
"pending_changes",
"app_settings",
}
for _, table := range tables {
if err := db.Exec("DELETE FROM " + table).Error; err != nil {
return fmt.Errorf("clear %s: %w", table, err)
}
}
slog.Info("local database data reset", "path", dbPath)
return nil
}
// New creates a new LocalDB instance
func New(dbPath string) (*LocalDB, error) {
// Ensure directory exists
@@ -49,6 +93,14 @@ func New(dbPath string) (*LocalDB, error) {
return nil, fmt.Errorf("creating data directory: %w", err)
}
if cfgPath, err := appstate.ResolveConfigPathNearDB("", dbPath); err == nil {
if _, err := appstate.EnsureRotatingLocalBackup(dbPath, cfgPath); err != nil {
return nil, fmt.Errorf("backup local data: %w", err)
}
} else {
return nil, fmt.Errorf("resolve config path: %w", err)
}
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
@@ -56,10 +108,31 @@ func New(dbPath string) (*LocalDB, error) {
return nil, fmt.Errorf("opening sqlite database: %w", err)
}
if err := ensureLocalProjectsTable(db); err != nil {
return nil, fmt.Errorf("ensure local_projects table: %w", err)
}
// Preflight: ensure local_projects has non-null UUIDs before AutoMigrate rebuilds tables.
if db.Migrator().HasTable(&LocalProject{}) {
if !db.Migrator().HasColumn(&LocalProject{}, "uuid") {
if err := db.Exec(`ALTER TABLE local_projects ADD COLUMN uuid TEXT`).Error; err != nil {
return nil, fmt.Errorf("adding local_projects.uuid: %w", err)
}
}
var ids []uint
if err := db.Raw(`SELECT id FROM local_projects WHERE uuid IS NULL OR uuid = ''`).Scan(&ids).Error; err != nil {
return nil, fmt.Errorf("finding local_projects without uuid: %w", err)
}
for _, id := range ids {
if err := db.Exec(`UPDATE local_projects SET uuid = ? WHERE id = ?`, uuidpkg.New().String(), id).Error; err != nil {
return nil, fmt.Errorf("backfilling local_projects.uuid: %w", err)
}
}
}
// Auto-migrate all local tables
if err := db.AutoMigrate(
&ConnectionSettings{},
&LocalProject{},
&LocalConfiguration{},
&LocalConfigurationVersion{},
&LocalPricelist{},
@@ -84,6 +157,38 @@ func New(dbPath string) (*LocalDB, error) {
}, nil
}
func ensureLocalProjectsTable(db *gorm.DB) error {
if db.Migrator().HasTable(&LocalProject{}) {
return nil
}
if err := db.Exec(`
CREATE TABLE local_projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
server_id INTEGER NULL,
owner_username TEXT NOT NULL,
code TEXT NOT NULL,
variant TEXT NOT NULL DEFAULT '',
name TEXT NULL,
tracker_url TEXT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
is_system INTEGER NOT NULL DEFAULT 0,
created_at DATETIME,
updated_at DATETIME,
synced_at DATETIME NULL,
sync_status TEXT DEFAULT 'local'
)`).Error; err != nil {
return err
}
_ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_owner_username ON local_projects(owner_username)`).Error
_ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_is_active ON local_projects(is_active)`).Error
_ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_is_system ON local_projects(is_system)`).Error
_ = db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code_variant ON local_projects(code, variant)`).Error
return nil
}
// HasSettings returns true if connection settings exist
func (l *LocalDB) HasSettings() bool {
var count int64
@@ -258,7 +363,8 @@ func (l *LocalDB) EnsureDefaultProject(ownerUsername string) (*LocalProject, err
project = &LocalProject{
UUID: uuidpkg.NewString(),
OwnerUsername: "",
Name: "Без проекта",
Code: "Без проекта",
Name: ptrString("Без проекта"),
IsActive: true,
IsSystem: true,
CreatedAt: now,
@@ -286,7 +392,8 @@ func (l *LocalDB) ConsolidateSystemProjects() (int64, error) {
canonical = LocalProject{
UUID: uuidpkg.NewString(),
OwnerUsername: "",
Name: "Без проекта",
Code: "Без проекта",
Name: ptrString("Без проекта"),
IsActive: true,
IsSystem: true,
CreatedAt: now,
@@ -367,6 +474,10 @@ WHERE (
return tx.RowsAffected, tx.Error
}
func ptrString(value string) *string {
return &value
}
// BackfillConfigurationProjects ensures every configuration has project_uuid set.
// If missing, it assigns system project "Без проекта" for configuration owner.
func (l *LocalDB) BackfillConfigurationProjects(defaultOwner string) error {
@@ -425,18 +536,36 @@ func (l *LocalDB) ListConfigurationsWithFilters(status string, search string, of
query := l.db.Model(&LocalConfiguration{})
switch status {
case "active":
query = query.Where("is_active = ?", true)
query = query.Where("local_configurations.is_active = ?", true)
case "archived":
query = query.Where("is_active = ?", false)
query = query.Where("local_configurations.is_active = ?", false)
case "all", "":
// no-op
default:
query = query.Where("is_active = ?", true)
query = query.Where("local_configurations.is_active = ?", true)
}
search = strings.TrimSpace(search)
if search != "" {
query = query.Where("LOWER(name) LIKE ?", "%"+strings.ToLower(search)+"%")
needle := "%" + strings.ToLower(search) + "%"
hasProjectsTable := l.db.Migrator().HasTable(&LocalProject{})
hasServerModel := l.db.Migrator().HasColumn(&LocalConfiguration{}, "server_model")
conditions := []string{"LOWER(local_configurations.name) LIKE ?"}
args := []interface{}{needle}
if hasProjectsTable {
query = query.Joins("LEFT JOIN local_projects lp ON lp.uuid = local_configurations.project_uuid")
conditions = append(conditions, "LOWER(COALESCE(lp.name, '')) LIKE ?")
args = append(args, needle)
}
if hasServerModel {
conditions = append(conditions, "LOWER(COALESCE(local_configurations.server_model, '')) LIKE ?")
args = append(args, needle)
}
query = query.Where(strings.Join(conditions, " OR "), args...)
}
var total int64
@@ -445,7 +574,7 @@ func (l *LocalDB) ListConfigurationsWithFilters(status string, search string, of
}
var configs []LocalConfiguration
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&configs).Error; err != nil {
if err := query.Order("local_configurations.created_at DESC").Offset(offset).Limit(limit).Find(&configs).Error; err != nil {
return nil, 0, err
}
return configs, total, nil
@@ -645,6 +774,17 @@ func (l *LocalDB) CountLocalPricelistItems(pricelistID uint) int64 {
return count
}
// CountLocalPricelistItemsWithEmptyCategory returns the number of items for a pricelist with missing lot_category.
func (l *LocalDB) CountLocalPricelistItemsWithEmptyCategory(pricelistID uint) (int64, error) {
var count int64
if err := l.db.Model(&LocalPricelistItem{}).
Where("pricelist_id = ? AND (lot_category IS NULL OR TRIM(lot_category) = '')", pricelistID).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
// SaveLocalPricelistItems saves pricelist items to local SQLite
func (l *LocalDB) SaveLocalPricelistItems(items []LocalPricelistItem) error {
if len(items) == 0 {
@@ -665,6 +805,30 @@ func (l *LocalDB) SaveLocalPricelistItems(items []LocalPricelistItem) error {
return nil
}
// ReplaceLocalPricelistItems atomically replaces all items for a pricelist.
func (l *LocalDB) ReplaceLocalPricelistItems(pricelistID uint, items []LocalPricelistItem) error {
return l.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("pricelist_id = ?", pricelistID).Delete(&LocalPricelistItem{}).Error; err != nil {
return err
}
if len(items) == 0 {
return nil
}
batchSize := 500
for i := 0; i < len(items); i += batchSize {
end := i + batchSize
if end > len(items) {
end = len(items)
}
if err := tx.CreateInBatches(items[i:end], batchSize).Error; err != nil {
return err
}
}
return nil
})
}
// GetLocalPricelistItems returns items for a local pricelist
func (l *LocalDB) GetLocalPricelistItems(pricelistID uint) ([]LocalPricelistItem, error) {
var items []LocalPricelistItem
@@ -684,6 +848,36 @@ func (l *LocalDB) GetLocalPriceForLot(pricelistID uint, lotName string) (float64
return item.Price, nil
}
// GetLocalLotCategoriesByServerPricelistID returns lot_category for each lot_name from a local pricelist resolved by server ID.
// Missing lots are not included in the map; caller is responsible for strict validation.
func (l *LocalDB) GetLocalLotCategoriesByServerPricelistID(serverPricelistID uint, lotNames []string) (map[string]string, error) {
result := make(map[string]string, len(lotNames))
if serverPricelistID == 0 || len(lotNames) == 0 {
return result, nil
}
localPL, err := l.GetLocalPricelistByServerID(serverPricelistID)
if err != nil {
return nil, err
}
type row struct {
LotName string `gorm:"column:lot_name"`
LotCategory string `gorm:"column:lot_category"`
}
var rows []row
if err := l.db.Model(&LocalPricelistItem{}).
Select("lot_name, lot_category").
Where("pricelist_id = ? AND lot_name IN ?", localPL.ID, lotNames).
Find(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
result[r.LotName] = r.LotCategory
}
return result, nil
}
// MarkPricelistAsUsed marks a pricelist as used by a configuration
func (l *LocalDB) MarkPricelistAsUsed(pricelistID uint, isUsed bool) error {
return l.db.Model(&LocalPricelist{}).Where("id = ?", pricelistID).

View File

@@ -51,8 +51,8 @@ func TestRunLocalMigrationsBackfillsDefaultProject(t *testing.T) {
if err != nil {
t.Fatalf("get system project: %v", err)
}
if project.Name != "Без проекта" {
t.Fatalf("expected system project name, got %q", project.Name)
if project.Name == nil || *project.Name != "Без проекта" {
t.Fatalf("expected system project name, got %v", project.Name)
}
if !project.IsSystem {
t.Fatalf("expected system project flag")

View File

@@ -68,6 +68,41 @@ var localMigrations = []localMigration{
name: "Add warehouse_pricelist_id and competitor_pricelist_id to local_configurations",
run: addWarehouseCompetitorPriceLists,
},
{
id: "2026_02_11_local_pricelist_item_category",
name: "Add lot_category to local_pricelist_items and create indexes",
run: addLocalPricelistItemCategoryAndIndexes,
},
{
id: "2026_02_11_local_config_article",
name: "Add article to local_configurations",
run: addLocalConfigurationArticle,
},
{
id: "2026_02_11_local_config_server_model",
name: "Add server_model to local_configurations",
run: addLocalConfigurationServerModel,
},
{
id: "2026_02_11_local_config_support_code",
name: "Add support_code to local_configurations",
run: addLocalConfigurationSupportCode,
},
{
id: "2026_02_13_local_project_code",
name: "Add project code to local_projects and backfill",
run: addLocalProjectCode,
},
{
id: "2026_02_13_local_project_variant",
name: "Add project variant to local_projects and backfill",
run: addLocalProjectVariant,
},
{
id: "2026_02_13_local_project_name_nullable",
name: "Allow NULL project names in local_projects",
run: allowLocalProjectNameNull,
},
}
func runLocalMigrations(db *gorm.DB) error {
@@ -204,7 +239,8 @@ func ensureDefaultProjectTx(tx *gorm.DB, ownerUsername string) (*LocalProject, e
project = LocalProject{
UUID: uuid.NewString(),
OwnerUsername: ownerUsername,
Name: "Без проекта",
Code: "Без проекта",
Name: ptrString("Без проекта"),
IsActive: true,
IsSystem: true,
CreatedAt: now,
@@ -218,6 +254,139 @@ func ensureDefaultProjectTx(tx *gorm.DB, ownerUsername string) (*LocalProject, e
return &project, nil
}
func addLocalProjectCode(tx *gorm.DB) error {
if err := tx.Exec(`ALTER TABLE local_projects ADD COLUMN code TEXT`).Error; err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "duplicate") &&
!strings.Contains(strings.ToLower(err.Error()), "exists") {
return err
}
}
// Drop unique index if it already exists to allow de-duplication updates.
if err := tx.Exec(`DROP INDEX IF EXISTS idx_local_projects_code`).Error; err != nil {
return err
}
// Copy code from current project name.
if err := tx.Exec(`
UPDATE local_projects
SET code = TRIM(COALESCE(name, ''))`).Error; err != nil {
return err
}
// Ensure any remaining blanks have a unique fallback.
if err := tx.Exec(`
UPDATE local_projects
SET code = 'P-' || uuid
WHERE code IS NULL OR TRIM(code) = ''`).Error; err != nil {
return err
}
// De-duplicate codes: OPS-1948-2, OPS-1948-3...
if err := tx.Exec(`
WITH ranked AS (
SELECT id, code,
ROW_NUMBER() OVER (PARTITION BY code ORDER BY id) AS rn
FROM local_projects
)
UPDATE local_projects
SET code = code || '-' || (SELECT rn FROM ranked WHERE ranked.id = local_projects.id)
WHERE id IN (SELECT id FROM ranked WHERE rn > 1)`).Error; err != nil {
return err
}
// Create unique index for project codes (ignore if exists).
if err := tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code ON local_projects(code)`).Error; err != nil {
return err
}
return nil
}
func addLocalProjectVariant(tx *gorm.DB) error {
if err := tx.Exec(`ALTER TABLE local_projects ADD COLUMN variant TEXT NOT NULL DEFAULT ''`).Error; err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "duplicate") &&
!strings.Contains(strings.ToLower(err.Error()), "exists") {
return err
}
}
// Drop legacy code index if present.
if err := tx.Exec(`DROP INDEX IF EXISTS idx_local_projects_code`).Error; err != nil {
return err
}
// Reset code from name and clear variant.
if err := tx.Exec(`
UPDATE local_projects
SET code = TRIM(COALESCE(name, '')),
variant = ''`).Error; err != nil {
return err
}
// De-duplicate by assigning variant numbers: 2,3...
if err := tx.Exec(`
WITH ranked AS (
SELECT id, code,
ROW_NUMBER() OVER (PARTITION BY code ORDER BY id) AS rn
FROM local_projects
)
UPDATE local_projects
SET variant = CASE
WHEN (SELECT rn FROM ranked WHERE ranked.id = local_projects.id) = 1 THEN ''
ELSE '-' || CAST((SELECT rn FROM ranked WHERE ranked.id = local_projects.id) AS TEXT)
END`).Error; err != nil {
return err
}
if err := tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code_variant ON local_projects(code, variant)`).Error; err != nil {
return err
}
return nil
}
func allowLocalProjectNameNull(tx *gorm.DB) error {
if err := tx.Exec(`ALTER TABLE local_projects RENAME TO local_projects_old`).Error; err != nil {
return err
}
if err := tx.Exec(`
CREATE TABLE local_projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
server_id INTEGER NULL,
owner_username TEXT NOT NULL,
code TEXT NOT NULL,
variant TEXT NOT NULL DEFAULT '',
name TEXT NULL,
tracker_url TEXT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
is_system INTEGER NOT NULL DEFAULT 0,
created_at DATETIME,
updated_at DATETIME,
synced_at DATETIME NULL,
sync_status TEXT DEFAULT 'local'
)`).Error; err != nil {
return err
}
_ = tx.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_owner_username ON local_projects(owner_username)`).Error
_ = tx.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_is_active ON local_projects(is_active)`).Error
_ = tx.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_is_system ON local_projects(is_system)`).Error
_ = tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code_variant ON local_projects(code, variant)`).Error
if err := tx.Exec(`
INSERT INTO local_projects (id, uuid, server_id, owner_username, code, variant, name, tracker_url, is_active, is_system, created_at, updated_at, synced_at, sync_status)
SELECT id, uuid, server_id, owner_username, code, variant, name, tracker_url, is_active, is_system, created_at, updated_at, synced_at, sync_status
FROM local_projects_old`).Error; err != nil {
return err
}
_ = tx.Exec(`DROP TABLE local_projects_old`).Error
return nil
}
func backfillConfigurationPricelists(tx *gorm.DB) error {
var latest LocalPricelist
if err := tx.Where("source = ?", "estimate").Order("created_at DESC").First(&latest).Error; err != nil {
@@ -259,6 +428,7 @@ func chooseNonZeroTime(candidate time.Time, fallback time.Time) time.Time {
return candidate
}
func fixLocalPricelistIndexes(tx *gorm.DB) error {
type indexRow struct {
Name string `gorm:"column:name"`
@@ -436,3 +606,112 @@ func addWarehouseCompetitorPriceLists(tx *gorm.DB) error {
slog.Info("added warehouse and competitor pricelist fields to local_configurations")
return nil
}
func addLocalPricelistItemCategoryAndIndexes(tx *gorm.DB) error {
type columnInfo struct {
Name string `gorm:"column:name"`
}
var columns []columnInfo
if err := tx.Raw(`
SELECT name FROM pragma_table_info('local_pricelist_items')
WHERE name IN ('lot_category')
`).Scan(&columns).Error; err != nil {
return fmt.Errorf("check local_pricelist_items(lot_category) existence: %w", err)
}
if len(columns) == 0 {
if err := tx.Exec(`
ALTER TABLE local_pricelist_items
ADD COLUMN lot_category TEXT
`).Error; err != nil {
return fmt.Errorf("add local_pricelist_items.lot_category: %w", err)
}
slog.Info("added lot_category to local_pricelist_items")
}
if err := tx.Exec(`
CREATE INDEX IF NOT EXISTS idx_local_pricelist_items_pricelist_lot
ON local_pricelist_items(pricelist_id, lot_name)
`).Error; err != nil {
return fmt.Errorf("ensure idx_local_pricelist_items_pricelist_lot: %w", err)
}
if err := tx.Exec(`
CREATE INDEX IF NOT EXISTS idx_local_pricelist_items_lot_category
ON local_pricelist_items(lot_category)
`).Error; err != nil {
return fmt.Errorf("ensure idx_local_pricelist_items_lot_category: %w", err)
}
return nil
}
func addLocalConfigurationArticle(tx *gorm.DB) error {
type columnInfo struct {
Name string `gorm:"column:name"`
}
var columns []columnInfo
if err := tx.Raw(`
SELECT name FROM pragma_table_info('local_configurations')
WHERE name IN ('article')
`).Scan(&columns).Error; err != nil {
return fmt.Errorf("check local_configurations(article) existence: %w", err)
}
if len(columns) == 0 {
if err := tx.Exec(`
ALTER TABLE local_configurations
ADD COLUMN article TEXT
`).Error; err != nil {
return fmt.Errorf("add local_configurations.article: %w", err)
}
slog.Info("added article to local_configurations")
}
return nil
}
func addLocalConfigurationServerModel(tx *gorm.DB) error {
type columnInfo struct {
Name string `gorm:"column:name"`
}
var columns []columnInfo
if err := tx.Raw(`
SELECT name FROM pragma_table_info('local_configurations')
WHERE name IN ('server_model')
`).Scan(&columns).Error; err != nil {
return fmt.Errorf("check local_configurations(server_model) existence: %w", err)
}
if len(columns) == 0 {
if err := tx.Exec(`
ALTER TABLE local_configurations
ADD COLUMN server_model TEXT
`).Error; err != nil {
return fmt.Errorf("add local_configurations.server_model: %w", err)
}
slog.Info("added server_model to local_configurations")
}
return nil
}
func addLocalConfigurationSupportCode(tx *gorm.DB) error {
type columnInfo struct {
Name string `gorm:"column:name"`
}
var columns []columnInfo
if err := tx.Raw(`
SELECT name FROM pragma_table_info('local_configurations')
WHERE name IN ('support_code')
`).Scan(&columns).Error; err != nil {
return fmt.Errorf("check local_configurations(support_code) existence: %w", err)
}
if len(columns) == 0 {
if err := tx.Exec(`
ALTER TABLE local_configurations
ADD COLUMN support_code TEXT
`).Error; err != nil {
return fmt.Errorf("add local_configurations.support_code: %w", err)
}
slog.Info("added support_code to local_configurations")
}
return nil
}

View File

@@ -96,6 +96,9 @@ type LocalConfiguration struct {
Notes string `json:"notes"`
IsTemplate bool `gorm:"default:false" json:"is_template"`
ServerCount int `gorm:"default:1" json:"server_count"`
ServerModel string `gorm:"size:100" json:"server_model,omitempty"`
SupportCode string `gorm:"size:20" json:"support_code,omitempty"`
Article string `gorm:"size:80" json:"article,omitempty"`
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
@@ -120,7 +123,9 @@ type LocalProject struct {
UUID string `gorm:"uniqueIndex;not null" json:"uuid"`
ServerID *uint `json:"server_id,omitempty"`
OwnerUsername string `gorm:"not null;index" json:"owner_username"`
Name string `gorm:"not null" json:"name"`
Code string `gorm:"not null;index:idx_local_projects_code_variant,priority:1" json:"code"`
Variant string `gorm:"default:'';index:idx_local_projects_code_variant,priority:2" json:"variant"`
Name *string `json:"name,omitempty"`
TrackerURL string `json:"tracker_url"`
IsActive bool `gorm:"default:true;index" json:"is_active"`
IsSystem bool `gorm:"default:false;index" json:"is_system"`
@@ -172,6 +177,7 @@ type LocalPricelistItem struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
PricelistID uint `gorm:"not null;index" json:"pricelist_id"`
LotName string `gorm:"not null" json:"lot_name"`
LotCategory string `gorm:"column:lot_category" json:"lot_category,omitempty"`
Price float64 `gorm:"not null" json:"price"`
AvailableQty *float64 `json:"available_qty,omitempty"`
Partnumbers LocalStringList `gorm:"type:text" json:"partnumbers,omitempty"`

View File

@@ -22,6 +22,9 @@ func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
"notes": localCfg.Notes,
"is_template": localCfg.IsTemplate,
"server_count": localCfg.ServerCount,
"server_model": localCfg.ServerModel,
"support_code": localCfg.SupportCode,
"article": localCfg.Article,
"pricelist_id": localCfg.PricelistID,
"only_in_stock": localCfg.OnlyInStock,
"price_updated_at": localCfg.PriceUpdatedAt,
@@ -52,6 +55,9 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
Notes string `json:"notes"`
IsTemplate bool `json:"is_template"`
ServerCount int `json:"server_count"`
ServerModel string `json:"server_model"`
SupportCode string `json:"support_code"`
Article string `json:"article"`
PricelistID *uint `json:"pricelist_id"`
OnlyInStock bool `json:"only_in_stock"`
PriceUpdatedAt *time.Time `json:"price_updated_at"`
@@ -78,6 +84,9 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
Notes: snapshot.Notes,
IsTemplate: snapshot.IsTemplate,
ServerCount: snapshot.ServerCount,
ServerModel: snapshot.ServerModel,
SupportCode: snapshot.SupportCode,
Article: snapshot.Article,
PricelistID: snapshot.PricelistID,
OnlyInStock: snapshot.OnlyInStock,
PriceUpdatedAt: snapshot.PriceUpdatedAt,

View File

@@ -53,6 +53,9 @@ type Configuration struct {
Notes string `gorm:"type:text" json:"notes"`
IsTemplate bool `gorm:"default:false" json:"is_template"`
ServerCount int `gorm:"default:1" json:"server_count"`
ServerModel string `gorm:"size:100" json:"server_model,omitempty"`
SupportCode string `gorm:"size:20" json:"support_code,omitempty"`
Article string `gorm:"size:80" json:"article,omitempty"`
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`

View File

@@ -55,6 +55,7 @@ type PricelistItem struct {
ID uint `gorm:"primaryKey" json:"id"`
PricelistID uint `gorm:"not null;index:idx_pricelist_lot" json:"pricelist_id"`
LotName string `gorm:"size:255;not null;index:idx_pricelist_lot" json:"lot_name"`
LotCategory string `gorm:"column:lot_category;size:50" json:"lot_category,omitempty"`
Price float64 `gorm:"type:decimal(12,2);not null" json:"price"`
PriceMethod string `gorm:"size:20" json:"price_method"`

View File

@@ -6,7 +6,9 @@ type Project struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
OwnerUsername string `gorm:"size:100;not null;index" json:"owner_username"`
Name string `gorm:"size:200;not null" json:"name"`
Code string `gorm:"size:100;not null;index:idx_qt_projects_code_variant,priority:1" json:"code"`
Variant string `gorm:"size:100;not null;default:'';index:idx_qt_projects_code_variant,priority:2" json:"variant"`
Name *string `gorm:"size:200" json:"name,omitempty"`
TrackerURL string `gorm:"size:500" json:"tracker_url"`
IsActive bool `gorm:"default:true;index" json:"is_active"`
IsSystem bool `gorm:"default:false;index" json:"is_system"`

View File

@@ -238,11 +238,7 @@ func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, sear
if err := r.db.Where("lot_name = ?", items[i].LotName).First(&lot).Error; err == nil {
items[i].LotDescription = lot.LotDescription
}
// Parse category from lot_name (e.g., "CPU_AMD_9654" -> "CPU")
parts := strings.SplitN(items[i].LotName, "_", 2)
if len(parts) >= 1 {
items[i].Category = parts[0]
}
items[i].Category = strings.TrimSpace(items[i].LotCategory)
}
if err := r.enrichItemsWithStock(items); err != nil {

View File

@@ -27,6 +27,8 @@ func (r *ProjectRepository) UpsertByUUID(project *models.Project) error {
Columns: []clause.Column{{Name: "uuid"}},
DoUpdates: clause.AssignmentColumns([]string{
"owner_username",
"code",
"variant",
"name",
"tracker_url",
"is_active",

View File

@@ -52,10 +52,20 @@ type CreateConfigRequest struct {
Notes string `json:"notes"`
IsTemplate bool `json:"is_template"`
ServerCount int `json:"server_count"`
ServerModel string `json:"server_model,omitempty"`
SupportCode string `json:"support_code,omitempty"`
Article string `json:"article,omitempty"`
PricelistID *uint `json:"pricelist_id,omitempty"`
OnlyInStock bool `json:"only_in_stock"`
}
type ArticlePreviewRequest struct {
Items models.ConfigItems `json:"items"`
ServerModel string `json:"server_model"`
SupportCode string `json:"support_code,omitempty"`
PricelistID *uint `json:"pricelist_id,omitempty"`
}
func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
if err != nil {
@@ -84,6 +94,9 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq
Notes: req.Notes,
IsTemplate: req.IsTemplate,
ServerCount: req.ServerCount,
ServerModel: req.ServerModel,
SupportCode: req.SupportCode,
Article: req.Article,
PricelistID: pricelistID,
OnlyInStock: req.OnlyInStock,
}
@@ -146,6 +159,9 @@ func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *Cr
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.OnlyInStock = req.OnlyInStock

View File

@@ -27,6 +27,7 @@ func NewExportService(cfg config.ExportConfig, categoryRepo *repository.Category
type ExportData struct {
Name string
Article string
Items []ExportItem
Total float64
Notes string
@@ -109,7 +110,7 @@ func (s *ExportService) ToCSV(w io.Writer, data *ExportData) error {
// Total row
totalStr := strings.ReplaceAll(fmt.Sprintf("%.2f", data.Total), ".", ",")
if err := csvWriter.Write([]string{"", "", "", "", "ИТОГО:", totalStr}); err != nil {
if err := csvWriter.Write([]string{data.Article, "", "", "", "ИТОГО:", totalStr}); err != nil {
return fmt.Errorf("failed to write total row: %w", err)
}
@@ -162,6 +163,7 @@ func (s *ExportService) ConfigToExportData(config *models.Configuration, compone
return &ExportData{
Name: config.Name,
Article: "",
Items: items,
Total: total,
Notes: config.Notes,

View File

@@ -8,6 +8,7 @@ import (
"time"
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
"git.mchus.pro/mchus/quoteforge/internal/article"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
@@ -64,6 +65,18 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
return nil, err
}
if strings.TrimSpace(req.ServerModel) != "" {
articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{
ServerModel: req.ServerModel,
SupportCode: req.SupportCode,
ServerPricelist: pricelistID,
})
if articleErr != nil {
return nil, articleErr
}
req.Article = articleResult.Article
}
total := req.Items.Total()
if req.ServerCount > 1 {
total *= float64(req.ServerCount)
@@ -80,6 +93,9 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
Notes: req.Notes,
IsTemplate: req.IsTemplate,
ServerCount: req.ServerCount,
ServerModel: req.ServerModel,
SupportCode: req.SupportCode,
Article: req.Article,
PricelistID: pricelistID,
OnlyInStock: req.OnlyInStock,
CreatedAt: time.Now(),
@@ -142,6 +158,18 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
return nil, err
}
if strings.TrimSpace(req.ServerModel) != "" {
articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{
ServerModel: req.ServerModel,
SupportCode: req.SupportCode,
ServerPricelist: pricelistID,
})
if articleErr != nil {
return nil, articleErr
}
req.Article = articleResult.Article
}
total := req.Items.Total()
if req.ServerCount > 1 {
total *= float64(req.ServerCount)
@@ -163,6 +191,9 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
localCfg.Notes = req.Notes
localCfg.IsTemplate = req.IsTemplate
localCfg.ServerCount = req.ServerCount
localCfg.ServerModel = req.ServerModel
localCfg.SupportCode = req.SupportCode
localCfg.Article = req.Article
localCfg.PricelistID = pricelistID
localCfg.OnlyInStock = req.OnlyInStock
localCfg.UpdatedAt = time.Now()
@@ -176,6 +207,19 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
return cfg, nil
}
// BuildArticlePreview generates server article based on current items and server_model/support_code.
func (s *LocalConfigurationService) BuildArticlePreview(req *ArticlePreviewRequest) (article.BuildResult, error) {
pricelistID, err := s.resolvePricelistID(req.PricelistID)
if err != nil {
return article.BuildResult{}, err
}
return article.Build(s.localDB, req.Items, article.BuildOptions{
ServerModel: req.ServerModel,
SupportCode: req.SupportCode,
ServerPricelist: pricelistID,
})
}
// Delete deletes a configuration from local SQLite and queues it for sync
func (s *LocalConfigurationService) Delete(uuid string, ownerUsername string) error {
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
@@ -269,6 +313,9 @@ func (s *LocalConfigurationService) CloneToProject(configUUID string, ownerUsern
Notes: original.Notes,
IsTemplate: false,
ServerCount: original.ServerCount,
ServerModel: original.ServerModel,
SupportCode: original.SupportCode,
Article: original.Article,
PricelistID: original.PricelistID,
OnlyInStock: original.OnlyInStock,
CreatedAt: time.Now(),
@@ -424,6 +471,18 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
return nil, err
}
if strings.TrimSpace(req.ServerModel) != "" {
articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{
ServerModel: req.ServerModel,
SupportCode: req.SupportCode,
ServerPricelist: pricelistID,
})
if articleErr != nil {
return nil, articleErr
}
req.Article = articleResult.Article
}
total := req.Items.Total()
if req.ServerCount > 1 {
total *= float64(req.ServerCount)
@@ -444,6 +503,9 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
localCfg.Notes = req.Notes
localCfg.IsTemplate = req.IsTemplate
localCfg.ServerCount = req.ServerCount
localCfg.ServerModel = req.ServerModel
localCfg.SupportCode = req.SupportCode
localCfg.Article = req.Article
localCfg.PricelistID = pricelistID
localCfg.OnlyInStock = req.OnlyInStock
localCfg.UpdatedAt = time.Now()

View File

@@ -191,7 +191,8 @@ func TestUpdateNoAuthKeepsProjectWhenProjectUUIDOmitted(t *testing.T) {
project := &localdb.LocalProject{
UUID: "project-keep",
OwnerUsername: "tester",
Name: "Keep Project",
Code: "TEST-KEEP",
Name: ptrString("Keep Project"),
IsActive: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
@@ -227,6 +228,10 @@ func TestUpdateNoAuthKeepsProjectWhenProjectUUIDOmitted(t *testing.T) {
}
}
func ptrString(value string) *string {
return &value
}
func newLocalConfigServiceForTest(t *testing.T) (*LocalConfigurationService, *localdb.LocalDB) {
t.Helper()

View File

@@ -16,8 +16,9 @@ import (
)
var (
ErrProjectNotFound = errors.New("project not found")
ErrProjectForbidden = errors.New("access to project forbidden")
ErrProjectNotFound = errors.New("project not found")
ErrProjectForbidden = errors.New("access to project forbidden")
ErrProjectCodeExists = errors.New("project code and variant already exist")
)
type ProjectService struct {
@@ -29,12 +30,16 @@ func NewProjectService(localDB *localdb.LocalDB) *ProjectService {
}
type CreateProjectRequest struct {
Name string `json:"name"`
Code string `json:"code"`
Variant string `json:"variant,omitempty"`
Name *string `json:"name,omitempty"`
TrackerURL string `json:"tracker_url"`
}
type UpdateProjectRequest struct {
Name string `json:"name"`
Code *string `json:"code,omitempty"`
Variant *string `json:"variant,omitempty"`
Name *string `json:"name,omitempty"`
TrackerURL *string `json:"tracker_url,omitempty"`
}
@@ -45,17 +50,30 @@ type ProjectConfigurationsResult struct {
}
func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest) (*models.Project, error) {
name := strings.TrimSpace(req.Name)
if name == "" {
return nil, fmt.Errorf("project name is required")
var namePtr *string
if req.Name != nil {
name := strings.TrimSpace(*req.Name)
if name != "" {
namePtr = &name
}
}
code := strings.TrimSpace(req.Code)
if code == "" {
return nil, fmt.Errorf("project code is required")
}
variant := strings.TrimSpace(req.Variant)
if err := s.ensureUniqueProjectCodeVariant("", code, variant); err != nil {
return nil, err
}
now := time.Now()
localProject := &localdb.LocalProject{
UUID: uuid.NewString(),
OwnerUsername: ownerUsername,
Name: name,
TrackerURL: normalizeProjectTrackerURL(name, req.TrackerURL),
Code: code,
Variant: variant,
Name: namePtr,
TrackerURL: normalizeProjectTrackerURL(code, req.TrackerURL),
IsActive: true,
IsSystem: false,
CreatedAt: now,
@@ -76,20 +94,33 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
if err != nil {
return nil, ErrProjectNotFound
}
if localProject.OwnerUsername != ownerUsername {
return nil, ErrProjectForbidden
if req.Code != nil {
code := strings.TrimSpace(*req.Code)
if code == "" {
return nil, fmt.Errorf("project code is required")
}
localProject.Code = code
}
if req.Variant != nil {
localProject.Variant = strings.TrimSpace(*req.Variant)
}
if err := s.ensureUniqueProjectCodeVariant(projectUUID, localProject.Code, localProject.Variant); err != nil {
return nil, err
}
name := strings.TrimSpace(req.Name)
if name == "" {
return nil, fmt.Errorf("project name is required")
if req.Name != nil {
name := strings.TrimSpace(*req.Name)
if name == "" {
localProject.Name = nil
} else {
localProject.Name = &name
}
}
localProject.Name = name
if req.TrackerURL != nil {
localProject.TrackerURL = normalizeProjectTrackerURL(name, *req.TrackerURL)
localProject.TrackerURL = normalizeProjectTrackerURL(localProject.Code, *req.TrackerURL)
} else if strings.TrimSpace(localProject.TrackerURL) == "" {
localProject.TrackerURL = normalizeProjectTrackerURL(name, "")
localProject.TrackerURL = normalizeProjectTrackerURL(localProject.Code, "")
}
localProject.UpdatedAt = time.Now()
localProject.SyncStatus = "pending"
@@ -102,6 +133,38 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
return localdb.LocalToProject(localProject), nil
}
func (s *ProjectService) ensureUniqueProjectCodeVariant(excludeUUID, code, variant string) error {
normalizedCode := normalizeProjectCode(code)
normalizedVariant := normalizeProjectVariant(variant)
if normalizedCode == "" {
return fmt.Errorf("project code is required")
}
projects, err := s.localDB.GetAllProjects(true)
if err != nil {
return err
}
for i := range projects {
project := projects[i]
if excludeUUID != "" && project.UUID == excludeUUID {
continue
}
if normalizeProjectCode(project.Code) == normalizedCode &&
normalizeProjectVariant(project.Variant) == normalizedVariant {
return ErrProjectCodeExists
}
}
return nil
}
func normalizeProjectCode(code string) string {
return strings.ToLower(strings.TrimSpace(code))
}
func normalizeProjectVariant(variant string) string {
return strings.ToLower(strings.TrimSpace(variant))
}
func (s *ProjectService) Archive(projectUUID, ownerUsername string) error {
return s.setProjectActive(projectUUID, ownerUsername, false)
}
@@ -116,9 +179,6 @@ func (s *ProjectService) setProjectActive(projectUUID, ownerUsername string, isA
if err := tx.Where("uuid = ?", projectUUID).First(&project).Error; err != nil {
return ErrProjectNotFound
}
if project.OwnerUsername != ownerUsername {
return ErrProjectForbidden
}
if project.IsActive == isActive {
return nil
}

View File

@@ -200,6 +200,7 @@ func (s *Service) ImportProjectsToLocal() (*ProjectImportResult, error) {
}
existing.OwnerUsername = project.OwnerUsername
existing.Code = project.Code
existing.Name = project.Name
existing.TrackerURL = project.TrackerURL
existing.IsActive = project.IsActive
@@ -388,6 +389,9 @@ func (s *Service) SyncPricelists() (int, error) {
slog.Info("deleted stale local pricelists", "deleted", removed)
}
// Backfill lot_category for used pricelists (older local caches may miss the column values).
s.backfillUsedPricelistItemCategories(pricelistRepo, serverPricelistIDs)
// Update last sync time
s.localDB.SetLastSyncTime(time.Now())
s.RecordSyncHeartbeat()
@@ -396,6 +400,83 @@ func (s *Service) SyncPricelists() (int, error) {
return synced, nil
}
func (s *Service) backfillUsedPricelistItemCategories(pricelistRepo *repository.PricelistRepository, activeServerPricelistIDs []uint) {
if s.localDB == nil || pricelistRepo == nil {
return
}
activeSet := make(map[uint]struct{}, len(activeServerPricelistIDs))
for _, id := range activeServerPricelistIDs {
activeSet[id] = struct{}{}
}
type row struct {
ID uint `gorm:"column:id"`
}
var usedRows []row
if err := s.localDB.DB().Raw(`
SELECT DISTINCT pricelist_id AS id
FROM local_configurations
WHERE is_active = 1 AND pricelist_id IS NOT NULL
UNION
SELECT DISTINCT warehouse_pricelist_id AS id
FROM local_configurations
WHERE is_active = 1 AND warehouse_pricelist_id IS NOT NULL
UNION
SELECT DISTINCT competitor_pricelist_id AS id
FROM local_configurations
WHERE is_active = 1 AND competitor_pricelist_id IS NOT NULL
`).Scan(&usedRows).Error; err != nil {
slog.Warn("pricelist category backfill: failed to list used pricelists", "error", err)
return
}
for _, r := range usedRows {
serverID := r.ID
if serverID == 0 {
continue
}
if _, ok := activeSet[serverID]; !ok {
// Not present on server (or not active) - cannot backfill from remote.
continue
}
localPL, err := s.localDB.GetLocalPricelistByServerID(serverID)
if err != nil || localPL == nil {
continue
}
if s.localDB.CountLocalPricelistItems(localPL.ID) == 0 {
continue
}
missing, err := s.localDB.CountLocalPricelistItemsWithEmptyCategory(localPL.ID)
if err != nil {
slog.Warn("pricelist category backfill: failed to check local items", "server_id", serverID, "error", err)
continue
}
if missing == 0 {
continue
}
serverItems, _, err := pricelistRepo.GetItems(serverID, 0, 10000, "")
if err != nil {
slog.Warn("pricelist category backfill: failed to load server items", "server_id", serverID, "error", err)
continue
}
localItems := make([]localdb.LocalPricelistItem, len(serverItems))
for i := range serverItems {
localItems[i] = *localdb.PricelistItemToLocal(&serverItems[i], localPL.ID)
}
if err := s.localDB.ReplaceLocalPricelistItems(localPL.ID, localItems); err != nil {
slog.Warn("pricelist category backfill: failed to replace local items", "server_id", serverID, "error", err)
continue
}
slog.Info("pricelist category backfill: refreshed local items", "server_id", serverID, "items", len(localItems))
}
}
// RecordSyncHeartbeat updates shared sync heartbeat for current DB user.
// Only users with write rights are expected to be able to update this table.
func (s *Service) RecordSyncHeartbeat() {
@@ -595,15 +676,7 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
// Convert and save locally
localItems := make([]localdb.LocalPricelistItem, len(serverItems))
for i, item := range serverItems {
partnumbers := make(localdb.LocalStringList, 0, len(item.Partnumbers))
partnumbers = append(partnumbers, item.Partnumbers...)
localItems[i] = localdb.LocalPricelistItem{
PricelistID: localPricelistID,
LotName: item.LotName,
Price: item.Price,
AvailableQty: item.AvailableQty,
Partnumbers: partnumbers,
}
localItems[i] = *localdb.PricelistItemToLocal(&item, localPricelistID)
}
if err := s.localDB.SaveLocalPricelistItems(localItems); err != nil {
@@ -776,6 +849,12 @@ func (s *Service) pushProjectChange(change *localdb.PendingChange) error {
projectRepo := repository.NewProjectRepository(mariaDB)
project := payload.Snapshot
project.UUID = payload.ProjectUUID
if strings.TrimSpace(project.Code) == "" {
project.Code = strings.TrimSpace(derefString(project.Name))
if project.Code == "" {
project.Code = project.UUID
}
}
if err := projectRepo.UpsertByUUID(&project); err != nil {
return fmt.Errorf("upsert project on server: %w", err)
@@ -796,6 +875,17 @@ func (s *Service) pushProjectChange(change *localdb.PendingChange) error {
return nil
}
func derefString(value *string) string {
if value == nil {
return ""
}
return *value
}
func ptrString(value string) *string {
return &value
}
func decodeProjectChangePayload(change *localdb.PendingChange) (ProjectChangePayload, error) {
var payload ProjectChangePayload
if err := json.Unmarshal([]byte(change.Payload), &payload); err == nil && payload.ProjectUUID != "" {
@@ -1066,7 +1156,8 @@ func (s *Service) ensureConfigurationProject(mariaDB *gorm.DB, cfg *models.Confi
systemProject = &models.Project{
UUID: uuid.NewString(),
OwnerUsername: "",
Name: "Без проекта",
Code: "Без проекта",
Name: ptrString("Без проекта"),
IsActive: true,
IsSystem: true,
}
@@ -1230,6 +1321,21 @@ func (s *Service) loadCurrentConfigurationState(configurationUUID string) (model
}
}
if currentVersionNo == 0 {
if err := s.repairMissingConfigurationVersion(localCfg); err != nil {
return models.Configuration{}, "", 0, fmt.Errorf("repair missing configuration version: %w", err)
}
var latest localdb.LocalConfigurationVersion
err = s.localDB.DB().
Where("configuration_uuid = ?", configurationUUID).
Order("version_no DESC").
First(&latest).Error
if err == nil {
currentVersionNo = latest.VersionNo
currentVersionID = latest.ID
}
}
if currentVersionNo == 0 {
return models.Configuration{}, "", 0, fmt.Errorf("no local configuration version found for %s", configurationUUID)
}
@@ -1237,6 +1343,64 @@ func (s *Service) loadCurrentConfigurationState(configurationUUID string) (model
return cfg, currentVersionID, currentVersionNo, nil
}
func (s *Service) repairMissingConfigurationVersion(localCfg *localdb.LocalConfiguration) error {
if localCfg == nil {
return fmt.Errorf("local configuration is nil")
}
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
var cfg localdb.LocalConfiguration
if err := tx.Where("uuid = ?", localCfg.UUID).First(&cfg).Error; err != nil {
return fmt.Errorf("load local configuration: %w", err)
}
// If versions exist, just make sure current_version_id is set.
var latest localdb.LocalConfigurationVersion
if err := tx.Where("configuration_uuid = ?", cfg.UUID).
Order("version_no DESC").
First(&latest).Error; err == nil {
if cfg.CurrentVersionID == nil || *cfg.CurrentVersionID == "" {
if err := tx.Model(&localdb.LocalConfiguration{}).
Where("uuid = ?", cfg.UUID).
Update("current_version_id", latest.ID).Error; err != nil {
return fmt.Errorf("set current version id: %w", err)
}
}
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("load latest version: %w", err)
}
snapshot, err := localdb.BuildConfigurationSnapshot(&cfg)
if err != nil {
return fmt.Errorf("build configuration snapshot: %w", err)
}
note := "Auto-repaired missing local version"
version := localdb.LocalConfigurationVersion{
ID: uuid.NewString(),
ConfigurationUUID: cfg.UUID,
VersionNo: 1,
Data: snapshot,
ChangeNote: &note,
AppVersion: appmeta.Version(),
CreatedAt: time.Now(),
}
if err := tx.Create(&version).Error; err != nil {
return fmt.Errorf("create initial version: %w", err)
}
if err := tx.Model(&localdb.LocalConfiguration{}).
Where("uuid = ?", cfg.UUID).
Update("current_version_id", version.ID).Error; err != nil {
return fmt.Errorf("set current version id: %w", err)
}
slog.Warn("repaired missing local configuration version", "uuid", cfg.UUID, "version_no", version.VersionNo)
return nil
})
}
// NOTE: prepared for future conflict resolution:
// when server starts storing version metadata, we can compare payload.CurrentVersionNo
// against remote version and branch into custom strategies. For now use last-write-wins.

View File

@@ -0,0 +1,107 @@
package sync_test
import (
"testing"
"time"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
)
func TestSyncPricelists_BackfillsLotCategoryForUsedPricelistItems(t *testing.T) {
local := newLocalDBForSyncTest(t)
serverDB := newServerDBForSyncTest(t)
if err := serverDB.AutoMigrate(
&models.Pricelist{},
&models.PricelistItem{},
&models.Lot{},
&models.LotPartnumber{},
&models.StockLog{},
); err != nil {
t.Fatalf("migrate server tables: %v", err)
}
serverPL := models.Pricelist{
Source: "estimate",
Version: "2026-02-11-001",
Notification: "server",
CreatedBy: "tester",
IsActive: true,
CreatedAt: time.Now().Add(-1 * time.Hour),
}
if err := serverDB.Create(&serverPL).Error; err != nil {
t.Fatalf("create server pricelist: %v", err)
}
if err := serverDB.Create(&models.PricelistItem{
PricelistID: serverPL.ID,
LotName: "CPU_A",
LotCategory: "CPU",
Price: 10,
PriceMethod: "",
MetaPrices: "",
ManualPrice: nil,
AvailableQty: nil,
}).Error; err != nil {
t.Fatalf("create server pricelist item: %v", err)
}
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
ServerID: serverPL.ID,
Source: serverPL.Source,
Version: serverPL.Version,
Name: serverPL.Notification,
CreatedAt: serverPL.CreatedAt,
SyncedAt: time.Now(),
IsUsed: false,
}); err != nil {
t.Fatalf("seed local pricelist: %v", err)
}
localPL, err := local.GetLocalPricelistByServerID(serverPL.ID)
if err != nil {
t.Fatalf("get local pricelist: %v", err)
}
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
{
PricelistID: localPL.ID,
LotName: "CPU_A",
LotCategory: "",
Price: 10,
},
}); err != nil {
t.Fatalf("seed local pricelist items: %v", err)
}
if err := local.SaveConfiguration(&localdb.LocalConfiguration{
UUID: "cfg-1",
OriginalUsername: "tester",
Name: "cfg",
Items: localdb.LocalConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 10}},
IsActive: true,
PricelistID: &serverPL.ID,
SyncStatus: "synced",
CreatedAt: time.Now().Add(-30 * time.Minute),
UpdatedAt: time.Now().Add(-30 * time.Minute),
}); err != nil {
t.Fatalf("seed local configuration with pricelist ref: %v", err)
}
svc := syncsvc.NewServiceWithDB(serverDB, local)
if _, err := svc.SyncPricelists(); err != nil {
t.Fatalf("sync pricelists: %v", err)
}
items, err := local.GetLocalPricelistItems(localPL.ID)
if err != nil {
t.Fatalf("load local items: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected 1 local item, got %d", len(items))
}
if items[0].LotCategory != "CPU" {
t.Fatalf("expected lot_category backfilled to CPU, got %q", items[0].LotCategory)
}
}

View File

@@ -23,7 +23,7 @@ func TestPushPendingChangesProjectsBeforeConfigurations(t *testing.T) {
projectService := services.NewProjectService(local)
configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false })
project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: "Project A"})
project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: ptrString("Project A"), Code: "PRJ-A"})
if err != nil {
t.Fatalf("create project: %v", err)
}
@@ -74,11 +74,11 @@ func TestPushPendingChangesProjectCreateThenUpdateBeforeFirstPush(t *testing.T)
configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false })
pushService := syncsvc.NewServiceWithDB(serverDB, local)
project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: "Project v1"})
project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: ptrString("Project v1"), Code: "PRJ-V1"})
if err != nil {
t.Fatalf("create project: %v", err)
}
if _, err := projectService.Update(project.UUID, "tester", &services.UpdateProjectRequest{Name: "Project v2"}); err != nil {
if _, err := projectService.Update(project.UUID, "tester", &services.UpdateProjectRequest{Name: ptrString("Project v2")}); err != nil {
t.Fatalf("update project: %v", err)
}
@@ -100,8 +100,8 @@ func TestPushPendingChangesProjectCreateThenUpdateBeforeFirstPush(t *testing.T)
if err := serverDB.Where("uuid = ?", project.UUID).First(&serverProject).Error; err != nil {
t.Fatalf("project not pushed to server: %v", err)
}
if serverProject.Name != "Project v2" {
t.Fatalf("expected latest project name, got %q", serverProject.Name)
if serverProject.Name == nil || *serverProject.Name != "Project v2" {
t.Fatalf("expected latest project name, got %v", serverProject.Name)
}
var serverCfg models.Configuration
@@ -324,6 +324,8 @@ CREATE TABLE qt_projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
owner_username TEXT NOT NULL,
code TEXT NOT NULL,
variant TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
tracker_url TEXT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
@@ -333,6 +335,9 @@ CREATE TABLE qt_projects (
);`).Error; err != nil {
t.Fatalf("create qt_projects: %v", err)
}
if err := db.Exec(`CREATE UNIQUE INDEX idx_qt_projects_code_variant ON qt_projects(code, variant);`).Error; err != nil {
t.Fatalf("create qt_projects index: %v", err)
}
if err := db.Exec(`
CREATE TABLE qt_configurations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -348,6 +353,9 @@ CREATE TABLE qt_configurations (
notes TEXT NULL,
is_template INTEGER NOT NULL DEFAULT 0,
server_count INTEGER NOT NULL DEFAULT 1,
server_model TEXT NULL,
support_code TEXT NULL,
article TEXT NULL,
pricelist_id INTEGER NULL,
warehouse_pricelist_id INTEGER NULL,
competitor_pricelist_id INTEGER NULL,
@@ -361,6 +369,10 @@ CREATE TABLE qt_configurations (
return db
}
func ptrString(value string) *string {
return &value
}
func getCurrentVersionInfo(t *testing.T, local *localdb.LocalDB, configurationUUID string, currentVersionID *string) (int, string) {
t.Helper()
if currentVersionID == nil || *currentVersionID == "" {

413
man/backup.md Normal file
View File

@@ -0,0 +1,413 @@
# AI Implementation Guide: Go Scheduled Backup Rotation (ZIP)
This document is written **for an AI** to replicate the same backup approach in another Go project. It contains the exact requirements, design notes, and full module listings you can copy.
## Requirements (Behavioral)
- Run backups on a daily schedule at a configured local time (default `00:00`).
- At startup, if there is no backup for the current period, create it immediately.
- Backup content must include:
- Local SQLite DB file (e.g., `qfs.db`).
- SQLite sidecars (`-wal`, `-shm`) if present.
- Runtime config file (e.g., `config.yaml`) if present.
- Backups must be ZIP archives named:
- `qfs-backp-YYYY-MM-DD.zip`
- Retention policy:
- 7 daily, 4 weekly, 12 monthly, 10 yearly archives.
- Keep backups in period-specific directories:
- `<backup root>/daily`, `/weekly`, `/monthly`, `/yearly`.
- Prevent duplicate backups for the same period via a marker file.
- Log success with the archive path, and log errors on failure.
## Configuration & Env
- Config key: `backup.time` with format `HH:MM` in local time. Default: `00:00`.
- Env overrides:
- `QFS_BACKUP_DIR` — backup root directory.
- `QFS_BACKUP_DISABLE` — disable backups (`1/true/yes`).
## Integration Steps (Minimal)
1. Add `BackupConfig` to your config struct.
2. Add a scheduler goroutine that:
- On startup: runs backup immediately if needed.
- Then sleeps until next configured time and runs daily.
3. Add the backup module (below).
4. Wire logs for success/failure.
---
# Full Go Listings
## 1) Backup Module (Drop-in)
Create: `internal/appstate/backup.go`
```go
package appstate
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
type backupPeriod struct {
name string
retention int
key func(time.Time) string
date func(time.Time) string
}
var backupPeriods = []backupPeriod{
{
name: "daily",
retention: 7,
key: func(t time.Time) string {
return t.Format("2006-01-02")
},
date: func(t time.Time) string {
return t.Format("2006-01-02")
},
},
{
name: "weekly",
retention: 4,
key: func(t time.Time) string {
y, w := t.ISOWeek()
return fmt.Sprintf("%04d-W%02d", y, w)
},
date: func(t time.Time) string {
return t.Format("2006-01-02")
},
},
{
name: "monthly",
retention: 12,
key: func(t time.Time) string {
return t.Format("2006-01")
},
date: func(t time.Time) string {
return t.Format("2006-01-02")
},
},
{
name: "yearly",
retention: 10,
key: func(t time.Time) string {
return t.Format("2006")
},
date: func(t time.Time) string {
return t.Format("2006-01-02")
},
},
}
const (
envBackupDisable = "QFS_BACKUP_DISABLE"
envBackupDir = "QFS_BACKUP_DIR"
)
var backupNow = time.Now
// EnsureRotatingLocalBackup creates or refreshes daily/weekly/monthly/yearly backups
// for the local database and config. It keeps a limited number per period.
func EnsureRotatingLocalBackup(dbPath, configPath string) ([]string, error) {
if isBackupDisabled() {
return nil, nil
}
if dbPath == "" {
return nil, nil
}
if _, err := os.Stat(dbPath); err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("stat db: %w", err)
}
root := resolveBackupRoot(dbPath)
now := backupNow()
created := make([]string, 0)
for _, period := range backupPeriods {
newFiles, err := ensurePeriodBackup(root, period, now, dbPath, configPath)
if err != nil {
return created, err
}
if len(newFiles) > 0 {
created = append(created, newFiles...)
}
}
return created, nil
}
func resolveBackupRoot(dbPath string) string {
if fromEnv := strings.TrimSpace(os.Getenv(envBackupDir)); fromEnv != "" {
return filepath.Clean(fromEnv)
}
return filepath.Join(filepath.Dir(dbPath), "backups")
}
func isBackupDisabled() bool {
val := strings.ToLower(strings.TrimSpace(os.Getenv(envBackupDisable)))
return val == "1" || val == "true" || val == "yes"
}
func ensurePeriodBackup(root string, period backupPeriod, now time.Time, dbPath, configPath string) ([]string, error) {
key := period.key(now)
periodDir := filepath.Join(root, period.name)
if err := os.MkdirAll(periodDir, 0755); err != nil {
return nil, fmt.Errorf("create %s backup dir: %w", period.name, err)
}
if hasBackupForKey(periodDir, key) {
return nil, nil
}
archiveName := fmt.Sprintf("qfs-backp-%s.zip", period.date(now))
archivePath := filepath.Join(periodDir, archiveName)
if err := createBackupArchive(archivePath, dbPath, configPath); err != nil {
return nil, fmt.Errorf("create %s backup archive: %w", period.name, err)
}
if err := writePeriodMarker(periodDir, key); err != nil {
return []string{archivePath}, err
}
if err := pruneOldBackups(periodDir, period.retention); err != nil {
return []string{archivePath}, err
}
return []string{archivePath}, nil
}
func hasBackupForKey(periodDir, key string) bool {
marker := periodMarker{Key: ""}
data, err := os.ReadFile(periodMarkerPath(periodDir))
if err != nil {
return false
}
if err := json.Unmarshal(data, &marker); err != nil {
return false
}
return marker.Key == key
}
type periodMarker struct {
Key string `json:"key"`
}
func periodMarkerPath(periodDir string) string {
return filepath.Join(periodDir, ".period.json")
}
func writePeriodMarker(periodDir, key string) error {
data, err := json.MarshalIndent(periodMarker{Key: key}, "", " ")
if err != nil {
return err
}
return os.WriteFile(periodMarkerPath(periodDir), data, 0644)
}
func pruneOldBackups(periodDir string, keep int) error {
entries, err := os.ReadDir(periodDir)
if err != nil {
return fmt.Errorf("read backups dir: %w", err)
}
files := make([]os.DirEntry, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
if strings.HasSuffix(entry.Name(), ".zip") {
files = append(files, entry)
}
}
if len(files) <= keep {
return nil
}
sort.Slice(files, func(i, j int) bool {
infoI, errI := files[i].Info()
infoJ, errJ := files[j].Info()
if errI != nil || errJ != nil {
return files[i].Name() < files[j].Name()
}
return infoI.ModTime().Before(infoJ.ModTime())
})
for i := 0; i < len(files)-keep; i++ {
path := filepath.Join(periodDir, files[i].Name())
if err := os.Remove(path); err != nil {
return fmt.Errorf("remove old backup %s: %w", path, err)
}
}
return nil
}
func createBackupArchive(destPath, dbPath, configPath string) error {
file, err := os.Create(destPath)
if err != nil {
return err
}
defer file.Close()
zipWriter := zip.NewWriter(file)
if err := addZipFile(zipWriter, dbPath); err != nil {
_ = zipWriter.Close()
return err
}
_ = addZipOptionalFile(zipWriter, dbPath+"-wal")
_ = addZipOptionalFile(zipWriter, dbPath+"-shm")
if strings.TrimSpace(configPath) != "" {
_ = addZipOptionalFile(zipWriter, configPath)
}
if err := zipWriter.Close(); err != nil {
return err
}
return file.Sync()
}
func addZipOptionalFile(writer *zip.Writer, path string) error {
if _, err := os.Stat(path); err != nil {
return nil
}
return addZipFile(writer, path)
}
func addZipFile(writer *zip.Writer, path string) error {
in, err := os.Open(path)
if err != nil {
return err
}
defer in.Close()
info, err := in.Stat()
if err != nil {
return err
}
header, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
header.Name = filepath.Base(path)
header.Method = zip.Deflate
out, err := writer.CreateHeader(header)
if err != nil {
return err
}
_, err = io.Copy(out, in)
return err
}
```
---
## 2) Scheduler Hook (Main)
Add this to your `main.go` (or equivalent). This schedules daily backups and logs success.
```go
func startBackupScheduler(ctx context.Context, cfg *config.Config, dbPath, configPath string) {
if cfg == nil {
return
}
hour, minute, err := parseBackupTime(cfg.Backup.Time)
if err != nil {
slog.Warn("invalid backup time; using 00:00", "value", cfg.Backup.Time, "error", err)
hour = 0
minute = 0
}
// Startup check: if no backup exists for current periods, create now.
if created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath); backupErr != nil {
slog.Error("local backup failed", "error", backupErr)
} else if len(created) > 0 {
for _, path := range created {
slog.Info("local backup completed", "archive", path)
}
}
for {
next := nextBackupTime(time.Now(), hour, minute)
timer := time.NewTimer(time.Until(next))
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
start := time.Now()
created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath)
duration := time.Since(start)
if backupErr != nil {
slog.Error("local backup failed", "error", backupErr, "duration", duration)
} else {
for _, path := range created {
slog.Info("local backup completed", "archive", path, "duration", duration)
}
}
}
}
}
func parseBackupTime(value string) (int, int, error) {
if strings.TrimSpace(value) == "" {
return 0, 0, fmt.Errorf("empty backup time")
}
parsed, err := time.Parse("15:04", value)
if err != nil {
return 0, 0, err
}
return parsed.Hour(), parsed.Minute(), nil
}
func nextBackupTime(now time.Time, hour, minute int) time.Time {
location := now.Location()
target := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, location)
if !now.Before(target) {
target = target.Add(24 * time.Hour)
}
return target
}
```
---
## 3) Config Struct (Minimal)
Add to config:
```go
type BackupConfig struct {
Time string `yaml:"time"`
}
```
Default:
```go
if c.Backup.Time == "" {
c.Backup.Time = "00:00"
}
```
---
## Notes for Replication
- Keep `backup.time` in local time. Do **not** parse with timezone offsets unless required.
- The `.period.json` marker is what prevents duplicate backups within the same period.
- The archive file name only contains the date. Uniqueness is ensured by per-period directories and the period marker.
- If you change naming or retention, update both the file naming and prune logic together.

38
memory.md Normal file
View File

@@ -0,0 +1,38 @@
# Changes summary (2026-02-11)
Implemented strict `lot_category` flow using `pricelist_items.lot_category` only (no parsing from `lot_name`), plus local caching and backfill:
1. Local DB schema + migrations
- Added `lot_category` column to `local_pricelist_items` via `LocalPricelistItem` model.
- Added local migration `2026_02_11_local_pricelist_item_category` to add the column if missing and create indexes:
- `idx_local_pricelist_items_pricelist_lot (pricelist_id, lot_name)`
- `idx_local_pricelist_items_lot_category (lot_category)`
2. Server model/repository
- Added `LotCategory` field to `models.PricelistItem`.
- `PricelistRepository.GetItems` now sets `Category` from `LotCategory` (no parsing from `lot_name`).
3. Sync + local DB helpers
- `SyncPricelistItems` now saves `lot_category` into local cache via `PricelistItemToLocal`.
- Added `LocalDB.CountLocalPricelistItemsWithEmptyCategory` and `LocalDB.ReplaceLocalPricelistItems`.
- Added `LocalDB.GetLocalLotCategoriesByServerPricelistID` for strict category lookup.
- Added `SyncPricelists` backfill step: for used active pricelists with empty categories, force refresh items from server.
4. API handler
- `GET /api/pricelists/:id/items` returns `category` from `local_pricelist_items.lot_category` (no parsing from `lot_name`).
5. Article category foundation
- New package `internal/article`:
- `ResolveLotCategoriesStrict` pulls categories from local pricelist items and errors on missing category.
- `GroupForLotCategory` maps only allowed codes (CPU/MEM/GPU/M2/SSD/HDD/EDSFF/HHHL/NIC/HCA/DPU/PSU/PS) to article groups; excludes `SFP`.
- Error type `MissingCategoryForLotError` with base `ErrMissingCategoryForLot`.
6. Tests
- Added unit tests for converters and article category resolver.
- Added handler test to ensure `/api/pricelists/:id/items` returns `lot_category`.
- Added sync test for category backfill on used pricelist items.
- `go test ./...` passed.
Additional fixes (2026-02-11):
- Fixed article parsing bug: CPU/GPU parsers were swapped in `internal/article/generator.go`. CPU now uses last token from CPU lot; GPU uses model+memory from `GPU_vendor_model_mem_iface`.
- Adjusted configurator base tab layout to align labels on the same row (separate label row + input row grid).

View File

@@ -0,0 +1,2 @@
ALTER TABLE qt_configurations
ADD COLUMN IF NOT EXISTS article VARCHAR(80) NULL AFTER server_count;

View File

@@ -0,0 +1,2 @@
ALTER TABLE qt_configurations
ADD COLUMN IF NOT EXISTS server_model VARCHAR(100) NULL AFTER server_count;

View File

@@ -0,0 +1,2 @@
ALTER TABLE qt_configurations
ADD COLUMN IF NOT EXISTS support_code VARCHAR(20) NULL AFTER server_model;

View File

@@ -0,0 +1,38 @@
-- Add project code and enforce uniqueness
ALTER TABLE qt_projects
ADD COLUMN code VARCHAR(100) NULL AFTER owner_username;
-- Copy code from current project name (truncate to fit)
UPDATE qt_projects
SET code = LEFT(TRIM(COALESCE(name, '')), 100);
-- Fallback for any remaining blanks
UPDATE qt_projects
SET code = uuid
WHERE code IS NULL OR TRIM(code) = '';
-- Drop unique index if it already exists to allow de-duplication updates
DROP INDEX IF EXISTS idx_qt_projects_code ON qt_projects;
-- De-duplicate codes: OPS-1948-2, OPS-1948-3... (MariaDB without CTE)
UPDATE qt_projects p
JOIN (
SELECT p1.id,
p1.code AS base_code,
(
SELECT COUNT(*)
FROM qt_projects p2
WHERE p2.code = p1.code AND p2.id <= p1.id
) AS rn
FROM qt_projects p1
) r ON r.id = p.id
SET p.code = CASE
WHEN r.rn = 1 THEN r.base_code
ELSE CONCAT(LEFT(r.base_code, 90), '-', r.rn)
END;
ALTER TABLE qt_projects
MODIFY COLUMN code VARCHAR(100) NOT NULL;
CREATE UNIQUE INDEX idx_qt_projects_code ON qt_projects(code);

View File

@@ -0,0 +1,28 @@
-- Add project variant and reset codes from project names
ALTER TABLE qt_projects
ADD COLUMN variant VARCHAR(100) NOT NULL DEFAULT '' AFTER code;
-- Drop legacy unique index on code to allow duplicate codes
DROP INDEX IF EXISTS idx_qt_projects_code ON qt_projects;
DROP INDEX IF EXISTS idx_qt_projects_code_variant ON qt_projects;
-- Reset code from name and clear variant
UPDATE qt_projects
SET code = LEFT(TRIM(COALESCE(name, '')), 100),
variant = '';
-- De-duplicate by assigning variant numbers: -2, -3...
UPDATE qt_projects p
JOIN (
SELECT p1.id,
p1.code,
(SELECT COUNT(*)
FROM qt_projects p2
WHERE p2.code = p1.code AND p2.id <= p1.id) AS rn
FROM qt_projects p1
) r ON r.id = p.id
SET p.code = r.code,
p.variant = CASE WHEN r.rn = 1 THEN '' ELSE CONCAT('-', r.rn) END;
CREATE UNIQUE INDEX idx_qt_projects_code_variant ON qt_projects(code, variant);

View File

@@ -0,0 +1,4 @@
-- Allow NULL project names
ALTER TABLE qt_projects
MODIFY COLUMN name VARCHAR(200) NULL;

315
pricelists_window.md Normal file
View File

@@ -0,0 +1,315 @@
# Промпт для ИИ: Перенос паттерна Прайслист
Используй этот документ как промпт для ИИ при переносе реализации прайслиста в другой проект.
---
## Задача
Я имею рабочую реализацию окна "Прайслист" в проекте QuoteForge. Нужно перенести эту реализацию в проект [ДОП_ПРОЕКТ_НАЗВАНИЕ], сохраняя структуру, логику и UI/UX.
## Что перенести
### Frontend - Лист прайслистов (`/pricelists`)
**Файл источник:** QuoteForge/web/templates/pricelists.html
**Компоненты:**
1. **Таблица** - список прайслистов с колонками:
- Версия (монофонт)
- Тип (estimate/warehouse/competitor)
- Дата создания
- Автор (обычно "sync")
- Позиций (количество товаров)
- Исп. (использований)
- Статус (зеленый "Активен" / серый "Неактивен")
- Действия (Просмотр, Удалить если не используется)
2. **Пагинация** - навигация по страницам с активной страницей выделена
3. **Модальное окно** - "Создать прайслист" (если есть прав на запись)
**Что копировать:**
- HTML структуру таблицы из lines 10-30
- JavaScript функции:
- `loadPricelists(page)` - загрузка списка
- `renderPricelists(items)` - рендер таблицы
- `renderPagination(total, page, perPage)` - пагинация
- `checkPricelistWritePermission()` - проверка прав
- Модальные функции: `openCreateModal()`, `closeCreateModal()`, `createPricelist()`
- CSS классы Tailwind (скопируются как есть)
**Где использовать в дочернем проекте:**
- URL: `/pricelists` (или адаптировать под ваши маршруты)
- API: `GET /api/pricelists?page=1&per_page=20`
---
### Frontend - Детали прайслиста (`/pricelists/:id`)
**Файл источник:** QuoteForge/web/templates/pricelist_detail.html
**Компоненты:**
1. **Хлебная крошка** - кнопка назад на список
2. **Инфо-панель** - сводка по прайслисту:
- Версия (монофонт)
- Дата создания
- Автор
- Позиций (количество)
- Использований (в скольких конфигах)
- Статус (зеленый/серый)
- Истекает (дата или "-")
3. **Таблица товаров** - с поиском и пагинацией:
- Артикул (монофонт, lot_name)
- Категория (извлекается первая часть до "_")
- Описание (обрезается до 60 символов с "...")
- [УСЛОВНО] Доступно (qty) - только для warehouse источника
- [УСЛОВНО] Partnumbers - только для warehouse источника
- Цена, $ (с 2 знаками после запятой)
- Настройки (аббревиатуры: РУЧН, Сред, Взвеш.мед, периоды (1н, 1м, 3м, 1г), коэффициент, МЕТА)
4. **Поиск** - дебаунс 300мс, поиск по lot_name
5. **Динамические колонки** - qty и partnumbers скрываются/показываются в зависимости от source (warehouse или нет)
**Что копировать:**
- HTML структуру из lines 4-78
- JavaScript функции:
- `loadPricelistInfo()` - загрузка деталей прайслиста
- `loadItems(page)` - загрузка товаров
- `renderItems(items)` - рендер таблицы товаров
- `renderItemsPagination(total, page, perPage)` - пагинация товаров
- `isWarehouseSource()` - проверка источника
- `toggleWarehouseColumns()` - показать/скрыть conditional колонки
- `formatQty(qty)` - форматирование количества
- `formatPriceSettings(item)` - форматирование строки настроек
- `escapeHtml(text)` - экранирование HTML
- Debounce для поиска (lines 300-306)
- CSS классы Tailwind
- Логику conditional колонок (lines 152-164)
**Где использовать в дочернем проекте:**
- URL: `/pricelists/:id`
- API:
- `GET /api/pricelists/:id`
- `GET /api/pricelists/:id/items?page=1&per_page=50&search=...`
---
### Backend - Handler
**Файл источник:** QuoteForge/internal/handlers/pricelist.go
**Методы для реализации:**
1. **List** (lines 23-89)
- Параметры: `page`, `per_page`, `source` (фильтр), `active_only`
- Логика:
- Получить все прайслисты
- Отфильтровать по source (case-insensitive)
- Отсортировать по CreatedAt DESC (свежее сверху)
- Пагинировать
- Для каждого: посчитать товары (CountLocalPricelistItems), использования (IsUsed)
- Вернуть JSON с полями: id, source, version, created_by, item_count, usage_count, is_active, created_at, synced_from
2. **Get** (lines 92-116)
- Параметр: `id` (uint из URL)
- Логика:
- Получить прайслист по ID
- Вернуть его детали (id, source, version, item_count, is_active, created_at)
- 404 если не найден
3. **GetItems** (lines 119-181)
- Параметры: `id` (URL), `page`, `per_page`, `search` (query)
- Логика:
- Получить прайслист по ID
- Получить товары этого прайслиста
- Фильтровать по lot_name LIKE search (если передан)
- Посчитать total
- Пагинировать
- Для каждого товара: извлечь категорию из lot_name (первая часть до "_")
- Вернуть JSON: source, items (id, lot_name, price, category, available_qty, partnumbers), total, page, per_page
4. **GetLotNames** (lines 183-211)
- Параметр: `id` (URL)
- Логика:
- Получить все lot_names из этого прайслиста
- Отсортировать alphabetically
- Вернуть JSON: lot_names (array of strings), total
5. **GetLatest** (lines 214-233)
- Параметр: `source` (query, default "estimate")
- Логика:
- Нормализовать source (case-insensitive)
- Получить самый свежий прайслист по этому source
- Вернуть его детали
- 404 если не найден
**Регистрация маршрутов:**
```go
pricelists := api.Group("/pricelists")
{
pricelists.GET("", handler.List)
pricelists.GET("/latest", handler.GetLatest)
pricelists.GET("/:id", handler.Get)
pricelists.GET("/:id/items", handler.GetItems)
pricelists.GET("/:id/lots", handler.GetLotNames)
}
```
---
## Адаптация для другого проекта
### Что нужно изменить
1. **Источник данных**
- QuoteForge использует local DB (LocalPricelist, LocalPricelistItem)
- В вашем проекте: замените на ваши структуры/таблицы
- Сущность "прайслист" может называться по-другому
2. **API маршруты**
- `/api/pricelists` → ваш путь
- `:id` - может быть UUID вместо int, адаптировать parsing
3. **Имена полей**
- Если у вас нет поля `version` - используйте ID или дату
- Если нет `source` - опустить фильтр
- Если нет `IsUsed` - считать как всегда 0
4. **Структуры данных**
- Pricelist должна иметь: id, name/version, created_at, source, item_count
- PricelistItem должна иметь: id, lot_name, price, available_qty, partnumbers
5. **Условные колонки**
- Логика: если source == "warehouse", показать qty и partnumbers
- Адаптировать под ваши источники/типы
### Что копировать как есть
- **HTML структура** - таблицы, модали, классы Tailwind
- **JavaScript логика** - все функции загрузки, рендера, пагинации
- **CSS классы** - Tailwind работает везде одинаково
- **Форматирование функций** - formatPrice, formatQty, formatDate
---
## Пошаговая инструкция для ИИ
1. **Прочитай оба файла:**
- QuoteForge/web/templates/pricelists.html (список)
- QuoteForge/web/templates/pricelist_detail.html (детали)
- QuoteForge/internal/handlers/pricelist.go (backend)
2. **Определи структуры данных в дочернем проекте:**
- Какая таблица хранит "прайслисты"?
- Какие у неё поля?
- Как связаны товары?
3. **Адаптируй Backend:**
- Скопируй методы Handler
- Замени DB вызовы на вызовы вашего хранилища
- Замени имена полей в JSON ответах если нужно
- Убедись, что API возвращает нужный формат
4. **Адаптируй Frontend - Список:**
- Скопируй HTML таблицу
- Скопируй функции load/render/pagination
- Замени маршруты `/pricelists` → ваши
- Замени API endpoint → ваш
- Протестируй список загружается
5. **Адаптируй Frontend - Детали:**
- Скопируй HTML для деталей
- Скопируй функции loadInfo/loadItems/render
- Замени маршруты и endpoints
- Особое внимание на conditional колонки (toggleWarehouseColumns)
- Протестируй поиск работает
6. **Протестируй:**
- Список загружается
- Пагинация работает
- Детали открываются
- Поиск работает
- Conditional колонки показываются/скрываются правильно
- Форматирование цен и дат работает
---
## Пример адаптации
### Backend (было):
```go
func (h *PricelistHandler) List(c *gin.Context) {
localPLs, err := h.localDB.GetLocalPricelists()
// ...
}
```
### Backend (стало):
```go
func (h *CatalogHandler) List(c *gin.Context) {
catalogs, err := h.service.GetAllCatalogs(page, perPage)
// ...
}
```
### Frontend (было):
```javascript
const resp = await fetch(`/api/pricelists?page=${page}&per_page=20`);
```
### Frontend (стало):
```javascript
const resp = await fetch(`/api/catalogs?page=${page}&per_page=20`);
```
---
## Качество результата
Когда закончишь:
- ✅ Список и детали выглядят идентично QuoteForge
-Все функции работают (load, render, pagination, search, conditional columns)
- ✅ Обработка ошибок (404, empty list, network errors)
- ✅ Таблицы с Tailwind классами оформлены одинаково
- ✅ Форматирование чисел/дат совпадает
---
## Вопросы для ИИ
Перед тем как давать этот промпт, ответь на эти вопросы:
1. **Какие у тебя структуры данных для "прайслиста"?**
- Пример: какие поля, как называется таблица
2. **Какие API endpoints уже есть?**
- Или нужно создать с нуля?
3. **Есть ли уже разница в источниках (estimate/warehouse)?**
- Или все одного типа?
4. **Нужна ли возможность создавать прайслисты?**
- Или только просмотр?
---
## Чеклист для проверки
После переноса проверь:
- [ ] Backend: List возвращает правильный JSON
- [ ] Backend: Get возвращает детали
- [ ] Backend: GetItems возвращает товары с поиском
- [ ] Frontend: Список загружается на `/pricelists`
- [ ] Frontend: Клик на прайслист открывает `/pricelists/:id`
- [ ] Frontend: Таблица на детальной странице рендеритсяся
- [ ] Frontend: Поиск работает с дебаунсом
- [ ] Frontend: Пагинация работает
- [ ] Frontend: Conditional колонки показываются/скрываются
- [ ] Frontend: Форматирование цен работает (2 знака)
- [ ] Frontend: Форматирование дат работает (ru-RU)
- [ ] UI: Выглядит идентично QuoteForge

95
releases/memory/v1.2.3.md Normal file
View File

@@ -0,0 +1,95 @@
# Release v1.2.3 (2026-02-10)
## Summary
Unified synchronization functionality with event-driven UI updates. Resolved user confusion about duplicate sync buttons by implementing a single sync source with automatic page refreshes.
## Changes
### Main Feature: Sync Event System
- **Added `sync-completed` event** in base.html's `syncAction()` function
- Dispatched after successful `/api/sync/all` or `/api/sync/push`
- Includes endpoint and response data in event detail
- Enables pages to react automatically to sync completion
### Configs Page (`configs.html`)
- **Removed "Импорт с сервера" button** - duplicate functionality no longer needed
- **Updated layout** - changed from 2-column grid to single button layout
- **Removed `importConfigsFromServer()` function** - functionality now handled by navbar sync
- **Added sync-completed event listener**:
- Automatically reloads configurations list after sync
- Resets pagination to first page
- New configurations appear immediately without manual refresh
### Projects Page (`projects.html`)
- **Wrapped initialization in DOMContentLoaded**:
- Moved `loadProjects()` and all event listeners inside handler
- Ensures DOM is fully loaded before accessing elements
- **Added sync-completed event listener**:
- Automatically reloads projects list after sync
- New projects appear immediately without manual refresh
### Pricelists Page (`pricelists.html`)
- **Added sync-completed event listener** to existing DOMContentLoaded:
- Automatically reloads pricelists when sync completes
- Maintains existing permissions and modal functionality
## Benefits
### User Experience
- ✅ Single "Синхронизация" button in navbar - no confusion about sync sources
- ✅ Automatic list updates after sync - no need for manual F5 refresh
- ✅ Consistent behavior across all pages (configs, projects, pricelists)
- ✅ Better feedback: toast notification + automatic UI refresh
### Architecture
- ✅ Event-driven loose coupling between navbar and pages
- ✅ Easy to extend to other pages (just add event listener)
- ✅ No backend changes needed
- ✅ Production-ready
## Breaking Changes
- **`/api/configs/import` endpoint** still works but UI button removed
- Users should use navbar "Синхронизация" button instead
- Backend API remains unchanged for backward compatibility
## Files Modified
1. `web/templates/base.html` - Added sync-completed event dispatch
2. `web/templates/configs.html` - Event listener + removed duplicate UI
3. `web/templates/projects.html` - DOMContentLoaded wrapper + event listener
4. `web/templates/pricelists.html` - Event listener for auto-refresh
**Stats:** 4 files changed, 59 insertions(+), 65 deletions(-)
## Commits
- `99fd80b` - feat: unify sync functionality with event-driven UI updates
## Testing Checklist
- [x] Configs page: New configurations appear after navbar sync
- [x] Projects page: New projects appear after navbar sync
- [x] Pricelists page: Pricelists refresh after navbar sync
- [x] Both `/api/sync/all` and `/api/sync/push` trigger updates
- [x] Toast notifications still show correctly
- [x] Sync status indicator updates
- [x] Error handling (423, network errors) still works
- [x] Mode switching (Active/Archive) works correctly
- [x] Backward compatibility maintained
## Known Issues
None - implementation is production-ready
## Migration Notes
No migration needed. Changes are frontend-only and backward compatible:
- Old `/api/configs/import` endpoint still functional
- No database schema changes
- No configuration changes needed

68
releases/memory/v1.3.0.md Normal file
View File

@@ -0,0 +1,68 @@
# Release v1.3.0 (2026-02-11)
## Summary
Introduced article generation with pricelist categories, added local configuration storage, and expanded sync/export capabilities. Simplified article generator compression and loosened project update constraints.
## Changes
### Main Features: Articles + Pricelist Categories
- **Article generation pipeline**
- New generator and tests under `internal/article/`
- Category support with test coverage
- **Pricelist category integration**
- Handler and repository updates
- Sync backfill test for category propagation
### Local Configuration Storage
- **Local DB support**
- New localdb models, converters, snapshots, and migrations
- Local configuration service for cached configurations
### Export & UI
- **Export handler updates** for article data output
- **Configs and index templates** adjusted for new article-related fields
### Behavior Changes
- **Cross-user project updates allowed**
- Removed restriction in project service
- **Article compression refinement**
- Generator logic simplified to reduce complexity
## Breaking Changes
None identified. Existing APIs remain intact.
## Files Modified
1. `internal/article/*` - Article generator + categories + tests
2. `internal/localdb/*` - Local DB models, migrations, snapshots
3. `internal/handlers/export.go` - Export updates
4. `internal/handlers/pricelist.go` - Category handling
5. `internal/services/sync/service.go` - Category backfill logic
6. `web/templates/configs.html` - Article field updates
7. `web/templates/index.html` - Article field updates
**Stats:** 33 files changed, 2059 insertions(+), 329 deletions(-)
## Commits
- `5edffe8` - Add article generation and pricelist categories
- `e355903` - Allow cross-user project updates
- `e58fd35` - Refine article compression and simplify generator
## Testing Checklist
- [ ] Tests not run (not requested)
## Migration Notes
- New migrations:
- `022_add_article_to_configurations.sql`
- `023_add_server_model_to_configurations.sql`
- `024_add_support_code_to_configurations.sql`
- Ensure migrations are applied before running v1.3.0

View File

@@ -62,7 +62,7 @@
<label class="block text-sm font-medium text-gray-700 mb-1">Код проекта</label>
<input id="create-project-input"
list="create-project-options"
placeholder="Начните вводить название проекта"
placeholder="Например: OPS-123 (Lenovo)"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<datalist id="create-project-options"></datalist>
<div class="mt-2 flex justify-between items-center gap-3">
@@ -147,7 +147,7 @@
<label class="block text-sm font-medium text-gray-700 mb-1">Проект</label>
<input id="move-project-input"
list="move-project-options"
placeholder="Начните вводить название проекта"
placeholder="Например: OPS-123 (Lenovo)"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<datalist id="move-project-options"></datalist>
<div class="mt-2 flex justify-between items-center gap-3">
@@ -174,7 +174,17 @@
<div id="create-project-on-move-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<h2 class="text-xl font-semibold mb-3">Проект не найден</h2>
<p class="text-sm text-gray-600 mb-4">Проект "<span id="create-project-on-move-name" class="font-medium text-gray-900"></span>" не найден. <span id="create-project-on-move-description">Создать и привязать квоту?</span></p>
<p class="text-sm text-gray-600 mb-4">Проект с кодом "<span id="create-project-on-move-code" class="font-medium text-gray-900"></span>" не найден. <span id="create-project-on-move-description">Создать и привязать квоту?</span></p>
<div class="mb-4">
<label for="create-project-on-move-name" class="block text-sm font-medium text-gray-700 mb-1">Название проекта</label>
<input id="create-project-on-move-name" type="text" placeholder="Например: Инфраструктура для OPS-123"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div class="mb-4">
<label for="create-project-on-move-variant" class="block text-sm font-medium text-gray-700 mb-1">Вариант (необязательно)</label>
<input id="create-project-on-move-variant" type="text" placeholder="Например: Lenovo"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div class="flex justify-end space-x-3">
<button onclick="closeCreateProjectOnMoveModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
<button id="create-project-on-move-confirm-btn" onclick="confirmCreateProjectOnMove()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Создать и привязать</button>
@@ -191,10 +201,12 @@ let configStatusMode = 'active';
let configsSearch = '';
let projectsCache = [];
let projectNameByUUID = {};
let projectCodeByUUID = {};
let projectVariantByUUID = {};
let pendingMoveConfigUUID = '';
let pendingMoveProjectName = '';
let pendingMoveProjectCode = '';
let pendingCreateConfigName = '';
let pendingCreateProjectName = '';
let pendingCreateProjectCode = '';
function renderConfigs(configs) {
const emptyText = configStatusMode === 'archived'
@@ -249,10 +261,23 @@ function renderConfigs(configs) {
html += '<td class="px-4 py-3 text-sm text-gray-700">' + escapeHtml(projectName) + '</td>';
}
}
const article = c.article ? escapeHtml(c.article) : '';
const serverModel = c.server_model ? escapeHtml(c.server_model) : '';
const subtitle = article || serverModel;
if (configStatusMode === 'archived') {
html += '<td class="px-4 py-3 text-sm font-medium text-gray-700">' + escapeHtml(c.name) + '</td>';
html += '<td class="px-4 py-3 text-sm font-medium text-gray-700">';
html += '<div>' + escapeHtml(c.name) + '</div>';
if (subtitle) {
html += '<div class="text-xs text-gray-500">' + subtitle + '</div>';
}
html += '</td>';
} else {
html += '<td class="px-4 py-3 text-sm font-medium"><a href="/configurator?uuid=' + c.uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(c.name) + '</a></td>';
html += '<td class="px-4 py-3 text-sm font-medium">';
html += '<a href="/configurator?uuid=' + c.uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(c.name) + '</a>';
if (subtitle) {
html += '<div class="text-xs text-gray-500">' + subtitle + '</div>';
}
html += '</td>';
}
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(author) + '</td>';
html += '<td class="px-4 py-3 text-sm text-gray-500">' + pricePerUnit + '</td>';
@@ -294,6 +319,30 @@ function renderConfigs(configs) {
document.getElementById('configs-list').innerHTML = html;
}
function projectDisplayKey(project) {
const code = (project.code || '').trim();
const variant = (project.variant || '').trim();
if (!code) return '';
return variant ? (code + ' (' + variant + ')') : code;
}
function findProjectByInput(input) {
const trimmed = (input || '').trim().toLowerCase();
if (!trimmed) return null;
const directMatch = projectsCache.find(p => projectDisplayKey(p).toLowerCase() === trimmed);
if (directMatch) return directMatch;
const codeMatches = projectsCache.filter(p => (p.code || '').toLowerCase() === trimmed);
if (codeMatches.length === 1) {
return codeMatches[0];
}
if (codeMatches.length > 1) {
alert('У проекта несколько вариантов. Укажите вариант в формате "CODE (variant)".');
}
return null;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
@@ -431,17 +480,21 @@ async function createConfig() {
return;
}
const projectName = document.getElementById('create-project-input').value.trim();
const projectCode = document.getElementById('create-project-input').value.trim();
let projectUUID = '';
if (projectName) {
const existingProject = projectsCache.find(p => p.is_active && p.name.toLowerCase() === projectName.toLowerCase());
if (existingProject) {
projectUUID = existingProject.uuid;
if (projectCode) {
const matchedProject = findProjectByInput(projectCode);
if (matchedProject) {
if (!matchedProject.is_active) {
alert('Проект с таким кодом находится в архиве. Восстановите его или выберите другой.');
return;
}
projectUUID = matchedProject.uuid;
} else {
pendingCreateConfigName = name;
pendingCreateProjectName = projectName;
openCreateProjectOnCreateModal(projectName);
pendingCreateProjectCode = projectCode;
openCreateProjectOnCreateModal(projectCode);
return;
}
}
@@ -489,12 +542,14 @@ function openMoveProjectModal(uuid, configName, currentProjectUUID) {
projectsCache.forEach(project => {
if (!project.is_active) return;
const option = document.createElement('option');
option.value = project.name;
option.value = projectDisplayKey(project);
option.label = project.name || '';
options.appendChild(option);
});
if (currentProjectUUID && projectNameByUUID[currentProjectUUID]) {
input.value = projectNameByUUID[currentProjectUUID];
if (currentProjectUUID && projectCodeByUUID[currentProjectUUID]) {
const variant = projectVariantByUUID[currentProjectUUID] || '';
input.value = variant ? (projectCodeByUUID[currentProjectUUID] + ' (' + variant + ')') : projectCodeByUUID[currentProjectUUID];
} else {
input.value = '';
}
@@ -510,19 +565,23 @@ function closeMoveProjectModal() {
async function confirmMoveProject() {
const uuid = document.getElementById('move-project-uuid').value;
const projectName = document.getElementById('move-project-input').value.trim();
const projectCode = document.getElementById('move-project-input').value.trim();
if (!uuid) return;
let projectUUID = '';
if (projectName) {
const existingProject = projectsCache.find(p => p.is_active && p.name.toLowerCase() === projectName.toLowerCase());
if (existingProject) {
projectUUID = existingProject.uuid;
if (projectCode) {
const matchedProject = findProjectByInput(projectCode);
if (matchedProject) {
if (!matchedProject.is_active) {
alert('Проект с таким кодом находится в архиве. Восстановите его или выберите другой.');
return;
}
projectUUID = matchedProject.uuid;
} else {
pendingMoveConfigUUID = uuid;
pendingMoveProjectName = projectName;
openCreateProjectOnMoveModal(projectName);
pendingMoveProjectCode = projectCode;
openCreateProjectOnMoveModal(projectCode);
return;
}
}
@@ -539,7 +598,9 @@ function clearCreateProjectInput() {
}
function openCreateProjectOnMoveModal(projectName) {
document.getElementById('create-project-on-move-name').textContent = projectName;
document.getElementById('create-project-on-move-code').textContent = projectName;
document.getElementById('create-project-on-move-name').value = projectName;
document.getElementById('create-project-on-move-variant').value = '';
document.getElementById('create-project-on-move-description').textContent = 'Создать и привязать квоту?';
document.getElementById('create-project-on-move-confirm-btn').textContent = 'Создать и привязать';
document.getElementById('create-project-on-move-modal').classList.remove('hidden');
@@ -547,7 +608,9 @@ function openCreateProjectOnMoveModal(projectName) {
}
function openCreateProjectOnCreateModal(projectName) {
document.getElementById('create-project-on-move-name').textContent = projectName;
document.getElementById('create-project-on-move-code').textContent = projectName;
document.getElementById('create-project-on-move-name').value = projectName;
document.getElementById('create-project-on-move-variant').value = '';
document.getElementById('create-project-on-move-description').textContent = 'Создать и использовать для новой конфигурации?';
document.getElementById('create-project-on-move-confirm-btn').textContent = 'Создать и использовать';
document.getElementById('create-project-on-move-modal').classList.remove('hidden');
@@ -558,22 +621,32 @@ function closeCreateProjectOnMoveModal() {
document.getElementById('create-project-on-move-modal').classList.add('hidden');
document.getElementById('create-project-on-move-modal').classList.remove('flex');
pendingMoveConfigUUID = '';
pendingMoveProjectName = '';
pendingMoveProjectCode = '';
pendingCreateConfigName = '';
pendingCreateProjectName = '';
pendingCreateProjectCode = '';
document.getElementById('create-project-on-move-name').value = '';
document.getElementById('create-project-on-move-variant').value = '';
}
async function confirmCreateProjectOnMove() {
if (pendingCreateConfigName && pendingCreateProjectName) {
const projectNameInput = document.getElementById('create-project-on-move-name');
const projectVariantInput = document.getElementById('create-project-on-move-variant');
const projectName = (projectNameInput.value || '').trim();
const projectVariant = (projectVariantInput.value || '').trim();
if (pendingCreateConfigName && pendingCreateProjectCode) {
const configName = pendingCreateConfigName;
const projectName = pendingCreateProjectName;
const projectCode = pendingCreateProjectCode;
try {
const createResp = await fetch('/api/projects', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ name: projectName })
body: JSON.stringify({ name: projectName, code: projectCode, variant: projectVariant })
});
if (!createResp.ok) {
if (createResp.status === 409) {
alert('Проект с таким кодом и вариантом уже существует');
return;
}
const err = await createResp.json();
alert('Не удалось создать проект: ' + (err.error || 'ошибка'));
return;
@@ -581,14 +654,14 @@ async function confirmCreateProjectOnMove() {
const newProject = await createResp.json();
pendingCreateConfigName = '';
pendingCreateProjectName = '';
pendingCreateProjectCode = '';
await loadProjectsForConfigUI();
const created = await createConfigWithProject(configName, newProject.uuid);
if (created) {
closeCreateProjectOnMoveModal();
} else {
closeCreateProjectOnMoveModal();
document.getElementById('create-project-input').value = projectName;
document.getElementById('create-project-input').value = projectCode;
}
} catch (e) {
alert('Ошибка создания проекта');
@@ -597,8 +670,8 @@ async function confirmCreateProjectOnMove() {
}
const configUUID = pendingMoveConfigUUID;
const projectName = pendingMoveProjectName;
if (!configUUID || !projectName) {
const projectCode = pendingMoveProjectCode;
if (!configUUID || !projectCode) {
closeCreateProjectOnMoveModal();
return;
}
@@ -607,9 +680,13 @@ async function confirmCreateProjectOnMove() {
const createResp = await fetch('/api/projects', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ name: projectName })
body: JSON.stringify({ name: projectName, code: projectCode, variant: projectVariant })
});
if (!createResp.ok) {
if (createResp.status === 409) {
alert('Проект с таким кодом и вариантом уже существует');
return;
}
const err = await createResp.json();
alert('Не удалось создать проект: ' + (err.error || 'ошибка'));
return;
@@ -617,9 +694,9 @@ async function confirmCreateProjectOnMove() {
const newProject = await createResp.json();
pendingMoveConfigUUID = '';
pendingMoveProjectName = '';
pendingMoveProjectCode = '';
await loadProjectsForConfigUI();
document.getElementById('move-project-input').value = projectName;
document.getElementById('move-project-input').value = projectCode;
const moved = await moveConfigToProject(configUUID, newProject.uuid);
if (moved) {
closeCreateProjectOnMoveModal();
@@ -806,6 +883,8 @@ document.getElementById('configs-search').addEventListener('input', function(e)
async function loadProjectsForConfigUI() {
projectsCache = [];
projectNameByUUID = {};
projectCodeByUUID = {};
projectVariantByUUID = {};
try {
// Use /api/projects/all to get all projects without pagination
const resp = await fetch('/api/projects/all');
@@ -818,7 +897,11 @@ async function loadProjectsForConfigUI() {
projectsCache = allProjects;
allProjects.forEach(project => {
projectNameByUUID[project.uuid] = project.name;
const variant = (project.variant || '').trim();
const baseName = project.name || '';
projectNameByUUID[project.uuid] = variant ? (baseName + ' (' + variant + ')') : baseName;
projectCodeByUUID[project.uuid] = project.code || '';
projectVariantByUUID[project.uuid] = project.variant || '';
});
const createOptions = document.getElementById('create-project-options');
@@ -827,7 +910,8 @@ async function loadProjectsForConfigUI() {
projectsCache.forEach(project => {
if (!project.is_active) return;
const option = document.createElement('option');
option.value = project.name;
option.value = projectDisplayKey(project);
option.label = project.name || '';
createOptions.appendChild(option);
});
}

View File

@@ -5,14 +5,25 @@
<!-- Header with config name and back button -->
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<a href="/configs" class="text-gray-500 hover:text-gray-700">
<a href="/projects" class="text-gray-500 hover:text-gray-700" title="Все проекты">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9.75L12 3l9 6.75v9A2.25 2.25 0 0118.75 21h-13.5A2.25 2.25 0 013 18.75v-9z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 21v-6h6v6"></path>
</svg>
</a>
<h1 class="text-2xl font-bold">
<span id="config-name">Конфигуратор</span>
</h1>
<div class="text-2xl font-bold flex items-center gap-2" id="config-breadcrumbs">
<a id="breadcrumb-project-code-link" href="/projects" class="text-blue-700 hover:underline">
<span id="breadcrumb-project-code"></span>
</a>
<span class="text-gray-400">-</span>
<a id="breadcrumb-project-variant-link" href="/projects" class="text-blue-700 hover:underline">
<span id="breadcrumb-project-variant">main</span>
</a>
<span class="text-gray-400">-</span>
<span id="breadcrumb-config-name">Конфигуратор</span>
<span class="text-gray-400">-</span>
<span id="breadcrumb-config-version">v1</span>
</div>
</div>
<div id="save-buttons" class="hidden flex items-center space-x-2">
<button id="refresh-prices-btn" onclick="refreshPrices()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
@@ -98,6 +109,7 @@
</svg>
</button>
<div id="cart-summary-content" class="p-4">
<div id="article-display" class="text-sm text-gray-700 mb-3 font-mono"></div>
<div id="cart-items" class="space-y-2 mb-4"></div>
<div class="border-t pt-3 flex justify-between items-center">
<div class="text-lg font-bold">
@@ -328,12 +340,77 @@ let configUUID = '{{.ConfigUUID}}';
let configName = '';
let projectUUID = '';
let projectName = '';
let projectCode = '';
let projectVariant = '';
let projectIndexLoaded = false;
let projectByUUID = {};
let projectMainByCode = {};
async function loadProjectIndex() {
if (projectIndexLoaded) return;
try {
const resp = await fetch('/api/projects/all');
if (!resp.ok) return;
const data = await resp.json();
const allProjects = Array.isArray(data) ? data : (data.projects || []);
projectByUUID = {};
projectMainByCode = {};
allProjects.forEach(p => {
projectByUUID[p.uuid] = p;
const code = (p.code || '').trim();
const variant = (p.variant || '').trim();
if (code && (variant === '' || variant === 'main')) {
if (!projectMainByCode[code]) {
projectMainByCode[code] = p.uuid;
}
}
});
projectIndexLoaded = true;
} catch (e) {
// ignore
}
}
function updateConfigBreadcrumbs() {
const codeEl = document.getElementById('breadcrumb-project-code');
const variantEl = document.getElementById('breadcrumb-project-variant');
const configEl = document.getElementById('breadcrumb-config-name');
const versionEl = document.getElementById('breadcrumb-config-version');
const projectCodeLinkEl = document.getElementById('breadcrumb-project-code-link');
const projectVariantLinkEl = document.getElementById('breadcrumb-project-variant-link');
let code = 'Без проекта';
let variant = 'main';
if (projectUUID && projectByUUID[projectUUID]) {
code = (projectByUUID[projectUUID].code || '').trim() || 'Без проекта';
const rawVariant = (projectByUUID[projectUUID].variant || '').trim();
variant = rawVariant === '' ? 'main' : rawVariant;
if (projectCodeLinkEl) {
const mainUUID = projectMainByCode[code];
projectCodeLinkEl.href = mainUUID ? ('/projects/' + mainUUID) : ('/projects/' + projectUUID);
}
if (projectVariantLinkEl) {
projectVariantLinkEl.href = '/projects/' + projectUUID;
}
} else {
if (projectCodeLinkEl) projectCodeLinkEl.href = '/projects';
if (projectVariantLinkEl) projectVariantLinkEl.href = '/projects';
}
codeEl.textContent = code;
variantEl.textContent = variant;
configEl.textContent = configName || 'Конфигурация';
versionEl.textContent = 'v1';
}
let currentTab = 'base';
let allComponents = [];
let cart = [];
let categoryOrderMap = {}; // Category code -> display_order mapping
let autoSaveTimeout = null; // Timeout for debounced autosave
let serverCount = 1; // Server count for the configuration
let serverModelForQuote = '';
let supportCode = '';
let currentArticle = '';
let articlePreviewTimeout = null;
let selectedPricelistIds = {
estimate: null,
warehouse: null,
@@ -612,7 +689,8 @@ document.addEventListener('DOMContentLoaded', async function() {
const config = await resp.json();
configName = config.name;
projectUUID = config.project_uuid || '';
document.getElementById('config-name').textContent = config.name;
await loadProjectIndex();
updateConfigBreadcrumbs();
document.getElementById('save-buttons').classList.remove('hidden');
// Set server count from config
@@ -634,6 +712,9 @@ document.addEventListener('DOMContentLoaded', async function() {
category: item.category || getCategoryFromLotName(item.lot_name)
}));
}
serverModelForQuote = config.server_model || '';
supportCode = config.support_code || '';
currentArticle = config.article || '';
// Restore custom price if saved
if (config.custom_price) {
@@ -948,7 +1029,32 @@ function renderTab() {
}
function renderSingleSelectTab(categories) {
let html = `
let html = '';
if (currentTab === 'base') {
html += `
<div class="mb-1 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
<label for="server-model-input" class="block text-sm font-medium text-gray-700">Модель сервера для КП:</label>
<label for="support-code-select" class="block text-sm font-medium text-gray-700">Уровень техподдержки:</label>
</div>
<div class="mb-3 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
<input type="text"
id="server-model-input"
value="${escapeHtml(serverModelForQuote)}"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
oninput="updateServerModelForQuote(this.value)">
<select id="support-code-select"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
onchange="updateSupportCode(this.value)">
<option value="">—</option>
<option value="1yW" ${supportCode === '1yW' ? 'selected' : ''}>1yW</option>
<option value="1yB" ${supportCode === '1yB' ? 'selected' : ''}>1yB</option>
<option value="1yS" ${supportCode === '1yS' ? 'selected' : ''}>1yS</option>
<option value="1yP" ${supportCode === '1yP' ? 'selected' : ''}>1yP</option>
</select>
</div>
`;
}
html += `
<table class="w-full">
<thead class="bg-gray-50">
<tr>
@@ -1636,6 +1742,8 @@ function updateCartUI() {
calculateCustomPrice();
renderSalePriceTable();
scheduleArticlePreview();
if (cart.length === 0) {
document.getElementById('cart-items').innerHTML =
'<div class="text-gray-500 text-center py-2">Конфигурация пуста</div>';
@@ -1711,6 +1819,69 @@ function escapeHtml(text) {
return div.innerHTML;
}
function updateServerModelForQuote(value) {
serverModelForQuote = value || '';
scheduleArticlePreview();
}
function updateSupportCode(value) {
supportCode = value || '';
scheduleArticlePreview();
}
function scheduleArticlePreview() {
if (articlePreviewTimeout) {
clearTimeout(articlePreviewTimeout);
}
articlePreviewTimeout = setTimeout(() => {
previewArticle();
}, 250);
}
async function previewArticle() {
const el = document.getElementById('article-display');
if (!el) return;
const model = serverModelForQuote.trim();
if (!model || !selectedPricelistIds.estimate || cart.length === 0) {
currentArticle = '';
el.textContent = 'Артикул: —';
return;
}
try {
const resp = await fetch('/api/configs/preview-article', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
server_model: serverModelForQuote,
support_code: supportCode,
pricelist_id: selectedPricelistIds.estimate,
items: cart.map(item => ({
lot_name: item.lot_name,
quantity: item.quantity,
unit_price: item.unit_price || 0
}))
})
});
if (!resp.ok) {
currentArticle = '';
el.textContent = 'Артикул: —';
return;
}
const data = await resp.json();
currentArticle = data.article || '';
el.textContent = currentArticle ? ('Артикул: ' + currentArticle) : 'Артикул: —';
} catch(e) {
currentArticle = '';
el.textContent = 'Артикул: —';
}
}
function getCurrentArticle() {
return currentArticle || '';
}
function triggerAutoSave() {
// Debounce autosave - wait 1 second after last change
if (autoSaveTimeout) {
@@ -1751,6 +1922,9 @@ async function saveConfig(showNotification = true) {
custom_price: customPrice,
notes: '',
server_count: serverCountValue,
server_model: serverModelForQuote,
support_code: supportCode,
article: getCurrentArticle(),
pricelist_id: selectedPricelistIds.estimate,
only_in_stock: onlyInStock
})
@@ -1795,17 +1969,19 @@ async function exportCSV() {
...item,
unit_price: getDisplayPrice(item),
}));
const article = getCurrentArticle();
const resp = await fetch('/api/export/csv', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({items: exportItems, name: configName, project_uuid: projectUUID})
body: JSON.stringify({items: exportItems, name: configName, project_uuid: projectUUID, article: article})
});
const blob = await resp.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = getFilenameFromResponse(resp) || (configName || 'config') + '.csv';
const articleForName = article || 'BOM';
a.download = getFilenameFromResponse(resp) || ((configName || 'config') + ' ' + articleForName + '.csv');
a.click();
window.URL.revokeObjectURL(url);
} catch(e) {

View File

@@ -3,9 +3,10 @@
{{define "content"}}
<div class="space-y-6">
<div class="flex items-center space-x-4">
<a href="/pricelists" class="text-gray-500 hover:text-gray-700">
<a href="/projects" class="text-gray-500 hover:text-gray-700" title="Все проекты">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9.75L12 3l9 6.75v9A2.25 2.25 0 0118.75 21h-13.5A2.25 2.25 0 013 18.75v-9z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 21v-6h6v6"></path>
</svg>
</a>
<h1 id="page-title" class="text-2xl font-bold text-gray-900">Загрузка...</h1>

View File

@@ -4,22 +4,45 @@
<div class="space-y-4">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3">
<a href="/projects" class="text-gray-500 hover:text-gray-700" title="Назад к проектам">
<a href="/projects" class="text-gray-500 hover:text-gray-700" title="Все проекты">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9.75L12 3l9 6.75v9A2.25 2.25 0 0118.75 21h-13.5A2.25 2.25 0 013 18.75v-9z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 21v-6h6v6"></path>
</svg>
</a>
<h1 class="text-2xl font-bold" id="project-title">Проект</h1>
<div class="text-2xl font-bold flex items-center gap-2">
<a id="project-code-link" href="/projects" class="text-blue-700 hover:underline">
<span id="project-code"></span>
</a>
<span class="text-gray-400">-</span>
<div class="relative">
<button id="project-variant-button" type="button" class="inline-flex items-center gap-2 text-base font-medium px-3 py-1.5 rounded-lg bg-gray-100 hover:bg-gray-200 border border-gray-200">
<span id="project-variant-label">main</span>
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<div id="project-variant-menu" class="absolute left-0 mt-2 min-w-[10rem] rounded-lg border border-gray-200 bg-white shadow-lg hidden z-10">
<div id="project-variant-list" class="py-1"></div>
</div>
</div>
</div>
</div>
</div>
<div id="action-buttons" class="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3">
<button onclick="openCreateModal()" class="py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
<div id="action-buttons" class="mt-4 grid grid-cols-1 sm:grid-cols-4 gap-3">
<button onclick="openNewVariantModal()" class="py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-medium">
+ Новый вариант
</button>
<button onclick="openCreateModal()" class="py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
+ Создать новую квоту
</button>
<button onclick="openImportModal()" class="py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 font-medium">
<button onclick="openImportModal()" class="py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 font-medium">
Импорт квоты
</button>
<button onclick="openProjectSettingsModal()" class="py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-800 font-medium">
Параметры
</button>
</div>
<div class="mt-2">
<a id="tracker-link" href="https://tracker.yandex.ru/OPS-1933" target="_blank" rel="noopener noreferrer" class="text-sm text-blue-600 hover:text-blue-800 hover:underline">
@@ -58,6 +81,33 @@
</div>
</div>
<div id="new-variant-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg p-6">
<h2 class="text-xl font-semibold mb-4">Новый вариант</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Код проекта</label>
<div id="new-variant-code" class="px-3 py-2 bg-gray-50 border rounded text-sm text-gray-700"></div>
</div>
<div>
<label for="new-variant-name" class="block text-sm font-medium text-gray-700 mb-1">Название (необязательно)</label>
<input id="new-variant-name" type="text" placeholder="Например: Lenovo"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label for="new-variant-value" class="block text-sm font-medium text-gray-700 mb-1">Вариант</label>
<input id="new-variant-value" type="text" placeholder="Например: Lenovo"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<div class="text-xs text-gray-500 mt-1">Оставьте пустым для main нельзя — нужно уникальное значение.</div>
</div>
</div>
<div class="mt-6 flex justify-end gap-2">
<button onclick="closeNewVariantModal()" class="px-4 py-2 text-gray-700 bg-gray-100 rounded hover:bg-gray-200">Отмена</button>
<button onclick="createNewVariant()" class="px-4 py-2 text-white bg-purple-600 rounded hover:bg-purple-700">Создать</button>
</div>
</div>
</div>
<div id="rename-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<h2 class="text-xl font-semibold mb-4">Переименовать квоту</h2>
@@ -113,11 +163,46 @@
</div>
</div>
<div id="project-settings-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<h2 class="text-xl font-semibold mb-4">Параметры проекта</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Код проекта</label>
<input type="text" id="project-settings-code"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Вариант (необязательно)</label>
<input type="text" id="project-settings-variant"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Название проекта</label>
<input type="text" id="project-settings-name"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Ссылка для "открыть в трекере"</label>
<input type="text" id="project-settings-tracker-url" placeholder="https://tracker.example.com/PROJ-123"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<div class="text-xs text-gray-500 mt-1">Оставьте пустым, чтобы скрыть ссылку.</div>
</div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button onclick="closeProjectSettingsModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
<button onclick="saveProjectSettings()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Сохранить</button>
</div>
</div>
</div>
<script>
const projectUUID = '{{.ProjectUUID}}';
let configStatusMode = 'active';
let project = null;
let allConfigs = [];
let projectVariants = [];
let variantMenuInitialized = false;
function escapeHtml(text) {
const div = document.createElement('div');
@@ -131,6 +216,91 @@ function resolveProjectTrackerURL(projectData) {
return explicitURL;
}
function formatProjectTitle(projectData) {
if (!projectData) return 'Проект';
const code = (projectData.code || '').trim();
const name = (projectData.name || '').trim();
const variant = (projectData.variant || '').trim();
if (!code) return name || 'Проект';
if (variant) {
return code + ': (' + variant + ') ' + (name || '');
}
return code + ': ' + (name || '');
}
function normalizeVariantLabel(variant) {
const trimmed = (variant || '').trim();
return trimmed === '' ? 'main' : trimmed;
}
async function loadVariantsForCode(code) {
if (!code) return;
try {
const resp = await fetch('/api/projects/all');
if (!resp.ok) return;
const data = await resp.json();
const allProjects = Array.isArray(data) ? data : (data.projects || []);
projectVariants = allProjects
.filter(p => (p.code || '').trim() === code)
.map(p => ({uuid: p.uuid, variant: (p.variant || '').trim()}));
projectVariants.sort((a, b) => normalizeVariantLabel(a.variant).localeCompare(normalizeVariantLabel(b.variant)));
} catch (e) {
// ignore
}
}
function renderVariantSelect() {
const list = document.getElementById('project-variant-list');
const menu = document.getElementById('project-variant-menu');
const button = document.getElementById('project-variant-button');
const label = document.getElementById('project-variant-label');
const codeLink = document.getElementById('project-code-link');
if (!list || !menu || !button || !label) return;
list.innerHTML = '';
const variants = projectVariants.length ? projectVariants : [{uuid: projectUUID, variant: (project && project.variant) || ''}];
let mainUUID = '';
variants.forEach(item => {
const variantLabel = normalizeVariantLabel(item.variant);
if (variantLabel === 'main' && !mainUUID) {
mainUUID = item.uuid;
}
const option = document.createElement('button');
option.type = 'button';
option.className = 'w-full text-left px-3 py-2 text-sm hover:bg-gray-50';
if (item.uuid === projectUUID) {
option.className += ' font-semibold text-gray-900';
label.textContent = variantLabel;
}
option.textContent = variantLabel;
option.onclick = function() {
menu.classList.add('hidden');
if (item.uuid && item.uuid !== projectUUID) {
window.location.href = '/projects/' + item.uuid;
}
};
list.appendChild(option);
});
if (codeLink) {
const targetMain = mainUUID || projectUUID;
codeLink.href = '/projects/' + targetMain;
}
if (!variantMenuInitialized) {
button.onclick = function(e) {
e.stopPropagation();
menu.classList.toggle('hidden');
};
document.addEventListener('click', function() {
menu.classList.add('hidden');
});
menu.addEventListener('click', function(e) {
e.stopPropagation();
});
variantMenuInitialized = true;
}
}
function setConfigStatusMode(mode) {
if (mode !== 'active' && mode !== 'archived') return;
configStatusMode = mode;
@@ -228,7 +398,9 @@ async function loadProject() {
return false;
}
project = await resp.json();
document.getElementById('project-title').textContent = project.name;
document.getElementById('project-code').textContent = project.code || '—';
await loadVariantsForCode(project.code || '');
renderVariantSelect();
const trackerLink = document.getElementById('tracker-link');
if (trackerLink) {
if (project && project.is_system) {
@@ -271,6 +443,56 @@ function openCreateModal() {
document.getElementById('create-name').focus();
}
function openNewVariantModal() {
if (!project) return;
document.getElementById('new-variant-code').textContent = (project.code || '').trim() || '—';
document.getElementById('new-variant-name').value = project.name || '';
document.getElementById('new-variant-value').value = '';
document.getElementById('new-variant-modal').classList.remove('hidden');
document.getElementById('new-variant-modal').classList.add('flex');
document.getElementById('new-variant-value').focus();
}
function closeNewVariantModal() {
document.getElementById('new-variant-modal').classList.add('hidden');
document.getElementById('new-variant-modal').classList.remove('flex');
}
async function createNewVariant() {
if (!project) return;
const code = (project.code || '').trim();
const variant = (document.getElementById('new-variant-value').value || '').trim();
const nameRaw = (document.getElementById('new-variant-name').value || '').trim();
if (!code || !variant) {
showToast('Укажите вариант', 'error');
return;
}
const payload = {
code: code,
variant: variant,
name: nameRaw ? nameRaw : null
};
const resp = await fetch('/api/projects', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
showToast(data.error || 'Ошибка создания варианта', 'error');
return;
}
const created = await resp.json().catch(() => null);
closeNewVariantModal();
showToast('Вариант создан', 'success');
if (created && created.uuid) {
window.location.href = '/projects/' + created.uuid;
return;
}
loadProject();
loadConfigs();
}
function closeCreateModal() {
document.getElementById('create-modal').classList.add('hidden');
document.getElementById('create-modal').classList.remove('flex');
@@ -397,6 +619,65 @@ function closeImportModal() {
document.getElementById('import-modal').classList.remove('flex');
}
function openProjectSettingsModal() {
if (!project) return;
if (project.is_system) {
alert('Системный проект нельзя редактировать');
return;
}
document.getElementById('project-settings-code').value = project.code || '';
document.getElementById('project-settings-variant').value = project.variant || '';
document.getElementById('project-settings-name').value = project.name || '';
document.getElementById('project-settings-tracker-url').value = (project.tracker_url || '').trim();
document.getElementById('project-settings-modal').classList.remove('hidden');
document.getElementById('project-settings-modal').classList.add('flex');
}
function closeProjectSettingsModal() {
document.getElementById('project-settings-modal').classList.add('hidden');
document.getElementById('project-settings-modal').classList.remove('flex');
}
async function saveProjectSettings() {
if (!project) return;
const code = document.getElementById('project-settings-code').value.trim();
const variant = document.getElementById('project-settings-variant').value.trim();
const name = document.getElementById('project-settings-name').value.trim();
const trackerURL = document.getElementById('project-settings-tracker-url').value.trim();
if (!code) {
alert('Введите код проекта');
return;
}
const resp = await fetch('/api/projects/' + projectUUID, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({code: code, variant: variant, name: name, tracker_url: trackerURL})
});
if (!resp.ok) {
if (resp.status === 409) {
alert('Проект с таким кодом и вариантом уже существует');
return;
}
alert('Не удалось сохранить параметры проекта');
return;
}
project = await resp.json();
document.getElementById('project-code').textContent = project.code || '—';
await loadVariantsForCode(project.code || '');
renderVariantSelect();
const trackerLink = document.getElementById('tracker-link');
if (trackerLink) {
const trackerURLResolved = resolveProjectTrackerURL(project);
if (trackerURLResolved) {
trackerLink.href = trackerURLResolved;
trackerLink.classList.remove('hidden');
} else {
trackerLink.classList.add('hidden');
}
}
closeProjectSettingsModal();
}
async function loadImportOptions() {
const resp = await fetch('/api/configs?page=1&per_page=500&status=active');
if (!resp.ok) return;
@@ -478,14 +759,17 @@ function wildcardMatch(value, pattern) {
document.getElementById('create-modal').addEventListener('click', function(e) { if (e.target === this) closeCreateModal(); });
document.getElementById('rename-modal').addEventListener('click', function(e) { if (e.target === this) closeRenameModal(); });
document.getElementById('new-variant-modal').addEventListener('click', function(e) { if (e.target === this) closeNewVariantModal(); });
document.getElementById('clone-modal').addEventListener('click', function(e) { if (e.target === this) closeCloneModal(); });
document.getElementById('import-modal').addEventListener('click', function(e) { if (e.target === this) closeImportModal(); });
document.getElementById('project-settings-modal').addEventListener('click', function(e) { if (e.target === this) closeProjectSettingsModal(); });
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeCreateModal();
closeRenameModal();
closeCloneModal();
closeImportModal();
closeProjectSettingsModal();
}
});

View File

@@ -20,7 +20,7 @@
</div>
<div class="max-w-md">
<input id="projects-search" type="text" placeholder="Поиск проекта по названию"
<input id="projects-search" type="text" placeholder="Поиск проекта по названию или коду"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
@@ -31,11 +31,21 @@
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<h2 class="text-xl font-semibold mb-4">Новый проект</h2>
<div class="space-y-4">
<div>
<label for="create-project-name" class="block text-sm font-medium text-gray-700 mb-1">Название проекта</label>
<input id="create-project-name" type="text" placeholder="Например: Инфраструктура для OPS-123"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label for="create-project-code" class="block text-sm font-medium text-gray-700 mb-1">Код проекта</label>
<input id="create-project-code" type="text" placeholder="Например: OPS-123"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label for="create-project-variant" class="block text-sm font-medium text-gray-700 mb-1">Вариант (необязательно)</label>
<input id="create-project-variant" type="text" placeholder="Например: Lenovo"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label for="create-project-tracker-url" class="block text-sm font-medium text-gray-700 mb-1">Ссылка на трекер</label>
<input id="create-project-tracker-url" type="url" placeholder="https://tracker.yandex.ru/OPS-123"
@@ -59,6 +69,8 @@ let sortField = 'created_at';
let sortDir = 'desc';
let createProjectTrackerManuallyEdited = false;
let createProjectLastAutoTrackerURL = '';
let variantsByCode = {};
let variantsLoaded = false;
const trackerBaseURL = 'https://tracker.yandex.ru/';
@@ -85,6 +97,55 @@ function formatDateTime(value) {
});
}
function normalizeVariant(variant) {
const trimmed = (variant || '').trim();
return trimmed === '' ? 'main' : trimmed;
}
function renderVariantChips(code, fallbackVariant, fallbackUUID) {
const variants = variantsByCode[code || ''] || [];
if (!variants.length) {
const single = normalizeVariant(fallbackVariant);
const href = fallbackUUID ? ('/projects/' + fallbackUUID) : '/projects';
return '<a href="' + href + '" class="inline-flex items-center px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600 hover:bg-gray-200 hover:text-gray-900">' + escapeHtml(single) + '</a>';
}
return variants.map(v => {
const href = v.uuid ? ('/projects/' + v.uuid) : '/projects';
return '<a href="' + href + '" class="inline-flex items-center px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-700 hover:bg-gray-200 hover:text-gray-900">' + escapeHtml(v.label) + '</a>';
}).join(' ');
}
async function loadVariantsIndex() {
if (variantsLoaded) return;
try {
const resp = await fetch('/api/projects/all');
if (!resp.ok) return;
const data = await resp.json();
const allProjects = Array.isArray(data) ? data : (data.projects || []);
variantsByCode = {};
allProjects.forEach(p => {
const code = (p.code || '').trim();
const variant = normalizeVariant(p.variant);
if (!variantsByCode[code]) {
variantsByCode[code] = [];
}
if (!variantsByCode[code].some(v => v.label === variant)) {
variantsByCode[code].push({label: variant, uuid: p.uuid});
}
});
Object.keys(variantsByCode).forEach(code => {
variantsByCode[code].sort((a, b) => {
if (a.label === 'main') return -1;
if (b.label === 'main') return 1;
return a.label.localeCompare(b.label);
});
});
variantsLoaded = true;
} catch (e) {
// ignore
}
}
function toggleSort(field) {
if (sortField === field) {
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
@@ -132,10 +193,33 @@ async function loadProjects() {
}
const data = await resp.json();
rows = data.projects || [];
if (Array.isArray(rows) && rows.length) {
const byCode = {};
rows.forEach(p => {
const codeKey = (p.code || '').trim();
if (!codeKey) {
const fallbackKey = p.uuid || Math.random().toString(36);
byCode[fallbackKey] = p;
return;
}
const variant = (p.variant || '').trim();
if (!byCode[codeKey]) {
byCode[codeKey] = p;
return;
}
const current = byCode[codeKey];
const currentVariant = (current.variant || '').trim();
if (currentVariant !== '' && variant === '') {
byCode[codeKey] = p;
}
});
rows = Object.values(byCode);
}
total = data.total || 0;
totalPages = data.total_pages || 0;
page = data.page || currentPage;
currentPage = page;
await loadVariantsIndex();
} catch (e) {
root.innerHTML = '<div class="text-red-600">Ошибка загрузки проектов: ' + escapeHtml(String(e.message || e)) + '</div>';
return;
@@ -144,27 +228,22 @@ async function loadProjects() {
let html = '<div class="overflow-x-auto"><table class="w-full">';
html += '<thead class="bg-gray-50">';
html += '<tr>';
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">';
html += '<button type="button" onclick="toggleSort(\'name\')" class="inline-flex items-center gap-1 hover:text-gray-700">Название проекта';
html += '<button type="button" onclick="toggleSort(\'name\')" class="inline-flex items-center gap-1 hover:text-gray-700">Название';
if (sortField === 'name') {
html += sortDir === 'asc' ? ' <span>↑</span>' : ' <span>↓</span>';
}
html += '</button></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">';
html += '<button type="button" onclick="toggleSort(\'created_at\')" class="inline-flex items-center gap-1 hover:text-gray-700">Создан';
if (sortField === 'created_at') {
html += sortDir === 'asc' ? ' <span>↑</span>' : ' <span>↓</span>';
}
html += '</button></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-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-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 += '</tr>';
html += '<tr>';
html += '<th class="px-4 py-2"></th>';
html += '<th class="px-4 py-2"><input id="projects-author-filter" type="text" value="' + escapeHtml(authorSearch) + '" placeholder="Фильтр автора" class="w-full px-2 py-1 border rounded text-xs focus:ring-1 focus:ring-blue-500 focus:border-blue-500"></th>';
html += '<th class="px-4 py-2"></th>';
html += '<th class="px-4 py-2"><input id="projects-author-filter" type="text" value="' + escapeHtml(authorSearch) + '" placeholder="Фильтр автора" class="w-full px-2 py-1 border rounded text-xs focus:ring-1 focus:ring-blue-500 focus:border-blue-500"></th>';
html += '<th class="px-4 py-2"></th>';
html += '<th class="px-4 py-2"></th>';
html += '<th class="px-4 py-2"></th>';
@@ -175,21 +254,28 @@ async function loadProjects() {
html += '<tr><td colspan="6" class="px-4 py-6 text-sm text-gray-500 text-center">Проектов нет</td></tr>';
}
rows.forEach(p => {
html += '<tr class="hover:bg-gray-50">';
html += '<td class="px-4 py-3 text-sm font-medium"><a class="text-blue-600 hover:underline" href="/projects/' + p.uuid + '">' + escapeHtml(p.name) + '</a></td>';
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(p.owner_username || '—') + '</td>';
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(formatDateTime(p.created_at)) + '</td>';
html += '<td class="px-4 py-3 text-sm text-right text-gray-700">' + (p.config_count || 0) + '</td>';
html += '<td class="px-4 py-3 text-sm text-right text-gray-700">' + formatMoney(p.total) + '</td>';
rows.forEach(p => {
html += '<tr class="hover:bg-gray-50">';
const displayName = p.name || '';
const createdBy = p.owner_username || '—';
const updatedBy = '';
const createdLabel = formatDateTime(p.created_at) + ' @ ' + createdBy;
const updatedLabel = formatDateTime(p.updated_at) + ' @ ' + updatedBy;
const variantChips = renderVariantChips(p.code, p.variant, p.uuid);
html += '<td class="px-4 py-3 text-sm font-medium"><a class="text-blue-600 hover:underline" href="/projects/' + p.uuid + '">' + escapeHtml(p.code || '—') + '</a></td>';
html += '<td class="px-4 py-3 text-sm text-gray-700">' + escapeHtml(displayName) + '</td>';
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(createdLabel) + '</td>';
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(updatedLabel) + '</td>';
html += '<td class="px-4 py-3 text-sm">' + variantChips + '</td>';
html += '<td class="px-4 py-3 text-sm text-right"><div class="inline-flex items-center gap-2">';
if (p.is_active) {
html += '<button onclick="copyProject(\'' + p.uuid + '\', \'' + escapeHtml(p.name).replace(/'/g, "\\'") + '\')" class="text-green-700 hover:text-green-900" title="Копировать">';
const safeName = escapeHtml(displayName).replace(/'/g, "\\'");
html += '<button onclick="copyProject(' + JSON.stringify(p.uuid) + ', ' + JSON.stringify(displayName) + ')" class="text-green-700 hover:text-green-900" title="Копировать">';
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>';
html += '</button>';
html += '<button onclick="renameProject(\'' + p.uuid + '\', \'' + escapeHtml(p.name).replace(/'/g, "\\'") + '\')" class="text-blue-700 hover:text-blue-900" title="Переименовать">';
html += '<button onclick="renameProject(' + JSON.stringify(p.uuid) + ', ' + JSON.stringify(displayName) + ')" class="text-blue-700 hover:text-blue-900" title="Переименовать">';
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>';
html += '</button>';
@@ -251,15 +337,19 @@ function buildTrackerURLFromProjectCode(projectCode) {
}
function openCreateProjectModal() {
const nameInput = document.getElementById('create-project-name');
const codeInput = document.getElementById('create-project-code');
const variantInput = document.getElementById('create-project-variant');
const trackerInput = document.getElementById('create-project-tracker-url');
nameInput.value = '';
codeInput.value = '';
variantInput.value = '';
trackerInput.value = '';
createProjectTrackerManuallyEdited = false;
createProjectLastAutoTrackerURL = '';
document.getElementById('create-project-modal').classList.remove('hidden');
document.getElementById('create-project-modal').classList.add('flex');
codeInput.focus();
nameInput.focus();
}
function closeCreateProjectModal() {
@@ -278,10 +368,14 @@ function updateCreateProjectTrackerURL() {
}
async function createProject() {
const nameInput = document.getElementById('create-project-name');
const codeInput = document.getElementById('create-project-code');
const variantInput = document.getElementById('create-project-variant');
const trackerInput = document.getElementById('create-project-tracker-url');
const name = (codeInput.value || '').trim();
if (!name) {
const name = (nameInput.value || '').trim();
const code = (codeInput.value || '').trim();
const variant = (variantInput.value || '').trim();
if (!code) {
alert('Введите код проекта');
return;
}
@@ -290,10 +384,16 @@ async function createProject() {
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
name: name,
code: code,
variant: variant,
tracker_url: (trackerInput.value || '').trim()
})
});
if (!resp.ok) {
if (resp.status === 409) {
alert('Проект с таким кодом и вариантом уже существует');
return;
}
alert('Не удалось создать проект');
return;
}
@@ -310,6 +410,10 @@ async function renameProject(projectUUID, currentName) {
body: JSON.stringify({name: name.trim()})
});
if (!resp.ok) {
if (resp.status === 409) {
alert('Проект с таким названием уже существует');
return;
}
alert('Не удалось переименовать проект');
return;
}
@@ -353,13 +457,20 @@ async function addConfigToProject(projectUUID) {
async function copyProject(projectUUID, projectName) {
const newName = prompt('Название копии проекта', projectName + ' (копия)');
if (!newName || !newName.trim()) return;
const newCode = prompt('Код проекта', '');
if (!newCode || !newCode.trim()) return;
const newVariant = prompt('Вариант (необязательно)', '');
const createResp = await fetch('/api/projects', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: newName.trim()})
body: JSON.stringify({name: newName.trim(), code: newCode.trim(), variant: (newVariant || '').trim()})
});
if (!createResp.ok) {
if (createResp.status === 409) {
alert('Проект с таким кодом и вариантом уже существует');
return;
}
alert('Не удалось создать копию проекта');
return;
}
@@ -398,6 +509,13 @@ document.addEventListener('DOMContentLoaded', function() {
updateCreateProjectTrackerURL();
});
document.getElementById('create-project-name').addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
createProject();
}
});
document.getElementById('create-project-tracker-url').addEventListener('input', function(e) {
createProjectTrackerManuallyEdited = (e.target.value || '').trim() !== createProjectLastAutoTrackerURL;
});