2 Commits

Author SHA1 Message Date
Mikhail Chusavitin
7b371add10 Merge branch 'stable'
# Conflicts:
#	bible/03-database.md
2026-02-24 15:13:41 +03:00
e5b6902c9e Implement persistent Line ordering for project specs and update bible 2026-02-21 07:09:38 +03:00
22 changed files with 891 additions and 111 deletions

View File

@@ -144,7 +144,7 @@ This prevents selecting empty/incomplete snapshots and removes nondeterministic
### Principle
Append-only: every save creates an immutable snapshot in `local_configuration_versions`.
Append-only for **spec+price** changes: immutable snapshots are stored in `local_configuration_versions`.
```
local_configurations
@@ -153,9 +153,11 @@ local_configurations
local_configuration_versions (v1)
```
- `version_no = max + 1` on every save
- `version_no = max + 1` when configuration **spec+price** changes
- Old versions are never modified or deleted in normal flow
- Rollback does **not** rewind history — it creates a **new** version from the snapshot
- Operational updates (`line_no` reorder, server count, project move, rename)
are synced via `pending_changes` but do **not** create a new revision snapshot
### Rollback
@@ -181,6 +183,19 @@ local → pending → synced
---
## Project Specification Ordering (`Line`)
- Each project configuration has persistent `line_no` (`10,20,30...`) in both SQLite and MariaDB.
- Project list ordering is deterministic:
`line_no ASC`, then `created_at DESC`, then `id DESC`.
- Drag-and-drop reorder in project UI updates `line_no` for active project configurations.
- Reorder writes are queued as configuration `update` events in `pending_changes`
without creating new configuration versions.
- Backward compatibility: if remote MariaDB schema does not yet include `line_no`,
sync falls back to create/update without `line_no` instead of failing.
---
## Sync Payload for Versioning
Events in `pending_changes` for configurations contain:

View File

@@ -25,7 +25,7 @@ File: `qfs.db` in the user-state directory (see [05-config.md](05-config.md)).
| Table | Purpose | Key Fields |
|-------|---------|------------|
| `local_configurations` | Saved configurations | `id`, `uuid` (unique), `items` (JSON), `pricelist_id`, `warehouse_pricelist_id`, `competitor_pricelist_id`, `current_version_id`, `sync_status` |
| `local_configurations` | Saved configurations | `id`, `uuid` (unique), `items` (JSON), `line_no`, `pricelist_id`, `warehouse_pricelist_id`, `competitor_pricelist_id`, `current_version_id`, `sync_status` |
| `local_configuration_versions` | Immutable snapshots (revisions) | `id`, `configuration_id` (FK), `version_no`, `data` (JSON), `change_note`, `created_at` |
| `local_projects` | Projects | `id`, `uuid` (unique), `name`, `code`, `sync_status` |
@@ -51,6 +51,7 @@ INDEX local_pricelists(source, created_at) -- used for "latest by source" quer
INDEX local_configurations(pricelist_id)
INDEX local_configurations(warehouse_pricelist_id)
INDEX local_configurations(competitor_pricelist_id)
INDEX local_configurations(project_uuid, line_no) -- project ordering (Line column)
UNIQUE INDEX local_configurations(uuid)
```
@@ -88,9 +89,9 @@ Database: `RFQ_LOG`
| `qt_categories` | Component categories | SELECT |
| `qt_pricelists` | Pricelists | SELECT |
| `qt_pricelist_items` | Pricelist line items | SELECT |
| `qt_configurations` | Saved configurations (includes `line_no`) | SELECT, INSERT, UPDATE |
| `lot_partnumbers` | Partnumber → lot mapping (pricelist enrichment) | SELECT |
| `stock_log` | Latest stock qty by partnumber (pricelist enrichment) | SELECT |
| `qt_configurations` | Saved configurations | SELECT, INSERT, UPDATE |
| `qt_projects` | Projects | SELECT, INSERT, UPDATE |
| `qt_client_local_migrations` | Migration catalog | SELECT only |
| `qt_client_schema_state` | Applied migrations state | SELECT, INSERT, UPDATE |

View File

@@ -57,6 +57,8 @@
| GET | `/api/configs/:uuid/versions` | List versions |
| GET | `/api/configs/:uuid/versions/:version` | Get specific version |
`line` field in configuration payloads is backed by persistent `line_no` in DB.
### Projects
| Method | Endpoint | Purpose |
@@ -67,6 +69,10 @@
| PUT | `/api/projects/:uuid` | Update project |
| DELETE | `/api/projects/:uuid` | Archive project variant (soft-delete via `is_active=false`; fails if project has no `variant` set — main projects cannot be deleted this way) |
| GET | `/api/projects/:uuid/configs` | Project configurations |
| PATCH | `/api/projects/:uuid/configs/reorder` | Reorder active project configurations (`ordered_uuids`) and persist `line_no` |
`GET /api/projects/:uuid/configs` ordering:
`line ASC`, then `created_at DESC`, then `id DESC`.
### Sync

View File

@@ -336,8 +336,6 @@ func derefString(value *string) string {
return *value
}
func setConfigDefaults(cfg *config.Config) {
if cfg.Server.Host == "" {
cfg.Server.Host = "127.0.0.1"
@@ -1662,6 +1660,43 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
c.JSON(http.StatusOK, result)
})
projects.PATCH("/:uuid/configs/reorder", func(c *gin.Context) {
var req struct {
OrderedUUIDs []string `json:"ordered_uuids"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(req.OrderedUUIDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ordered_uuids is required"})
return
}
configs, err := configService.ReorderProjectConfigurationsNoAuth(c.Param("uuid"), req.OrderedUUIDs)
if err != nil {
switch {
case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
return
}
total := 0.0
for i := range configs {
if configs[i].TotalPrice != nil {
total += *configs[i].TotalPrice
}
}
c.JSON(http.StatusOK, gin.H{
"project_uuid": c.Param("uuid"),
"configurations": configs,
"total": total,
})
})
projects.POST("/:uuid/configs", func(c *gin.Context) {
var req services.CreateConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {

View File

@@ -77,7 +77,7 @@ func TestConfigurationVersioningAPI(t *testing.T) {
if err := json.Unmarshal(rbRec.Body.Bytes(), &rbResp); err != nil {
t.Fatalf("unmarshal rollback response: %v", err)
}
if rbResp.Message == "" || rbResp.CurrentVersion.VersionNo != 3 {
if rbResp.Message == "" || rbResp.CurrentVersion.VersionNo != 2 {
t.Fatalf("unexpected rollback response: %+v", rbResp)
}

View File

@@ -33,6 +33,7 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
Article: cfg.Article,
PricelistID: cfg.PricelistID,
OnlyInStock: cfg.OnlyInStock,
Line: cfg.Line,
PriceUpdatedAt: cfg.PriceUpdatedAt,
CreatedAt: cfg.CreatedAt,
UpdatedAt: time.Now(),
@@ -80,6 +81,7 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
Article: local.Article,
PricelistID: local.PricelistID,
OnlyInStock: local.OnlyInStock,
Line: local.Line,
PriceUpdatedAt: local.PriceUpdatedAt,
CreatedAt: local.CreatedAt,
}

View File

@@ -253,3 +253,63 @@ func TestRunLocalMigrationsDeduplicatesConfigurationVersionsBySpecAndPrice(t *te
t.Fatalf("expected current_version_id to point to kept latest version v3")
}
}
func TestRunLocalMigrationsBackfillsConfigurationLineNo(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "line_no_backfill.db")
local, err := New(dbPath)
if err != nil {
t.Fatalf("open localdb: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
projectUUID := "project-line"
cfg1 := &LocalConfiguration{
UUID: "line-cfg-1",
ProjectUUID: &projectUUID,
Name: "Cfg 1",
Items: LocalConfigItems{},
SyncStatus: "pending",
OriginalUsername: "tester",
IsActive: true,
CreatedAt: time.Now().Add(-2 * time.Hour),
}
cfg2 := &LocalConfiguration{
UUID: "line-cfg-2",
ProjectUUID: &projectUUID,
Name: "Cfg 2",
Items: LocalConfigItems{},
SyncStatus: "pending",
OriginalUsername: "tester",
IsActive: true,
CreatedAt: time.Now().Add(-1 * time.Hour),
}
if err := local.SaveConfiguration(cfg1); err != nil {
t.Fatalf("save cfg1: %v", err)
}
if err := local.SaveConfiguration(cfg2); err != nil {
t.Fatalf("save cfg2: %v", err)
}
if err := local.DB().Model(&LocalConfiguration{}).Where("uuid IN ?", []string{cfg1.UUID, cfg2.UUID}).Update("line_no", 0).Error; err != nil {
t.Fatalf("reset line_no: %v", err)
}
if err := local.DB().Where("id = ?", "2026_02_19_local_config_line_no").Delete(&LocalSchemaMigration{}).Error; err != nil {
t.Fatalf("delete migration record: %v", err)
}
if err := runLocalMigrations(local.DB()); err != nil {
t.Fatalf("rerun local migrations: %v", err)
}
var rows []LocalConfiguration
if err := local.DB().Where("uuid IN ?", []string{cfg1.UUID, cfg2.UUID}).Order("created_at ASC").Find(&rows).Error; err != nil {
t.Fatalf("load configurations: %v", err)
}
if len(rows) != 2 {
t.Fatalf("expected 2 configurations, got %d", len(rows))
}
if rows[0].Line != 10 || rows[1].Line != 20 {
t.Fatalf("expected line_no [10,20], got [%d,%d]", rows[0].Line, rows[1].Line)
}
}

View File

@@ -341,7 +341,7 @@ func (l *LocalDB) GetProjectByName(ownerUsername, name string) (*LocalProject, e
func (l *LocalDB) GetProjectConfigurations(projectUUID string) ([]LocalConfiguration, error) {
var configs []LocalConfiguration
err := l.db.Where("project_uuid = ? AND is_active = ?", projectUUID, true).
Order("created_at DESC").
Order(configurationLineOrderClause()).
Find(&configs).Error
return configs, err
}
@@ -514,9 +514,54 @@ func (l *LocalDB) BackfillConfigurationProjects(defaultOwner string) error {
// SaveConfiguration saves a configuration to local SQLite
func (l *LocalDB) SaveConfiguration(config *LocalConfiguration) error {
if config != nil && config.IsActive && config.Line <= 0 {
line, err := l.NextConfigurationLine(config.ProjectUUID, config.UUID)
if err != nil {
return err
}
config.Line = line
}
return l.db.Save(config).Error
}
func (l *LocalDB) NextConfigurationLine(projectUUID *string, excludeUUID string) (int, error) {
return NextConfigurationLineTx(l.db, projectUUID, excludeUUID)
}
func NextConfigurationLineTx(tx *gorm.DB, projectUUID *string, excludeUUID string) (int, error) {
query := tx.Model(&LocalConfiguration{}).
Where("is_active = ?", true)
trimmedExclude := strings.TrimSpace(excludeUUID)
if trimmedExclude != "" {
query = query.Where("uuid <> ?", trimmedExclude)
}
if projectUUID != nil && strings.TrimSpace(*projectUUID) != "" {
query = query.Where("project_uuid = ?", strings.TrimSpace(*projectUUID))
} else {
query = query.Where("project_uuid IS NULL OR TRIM(project_uuid) = ''")
}
var maxLine int
if err := query.Select("COALESCE(MAX(line_no), 0)").Scan(&maxLine).Error; err != nil {
return 0, fmt.Errorf("read max line_no: %w", err)
}
if maxLine < 0 {
maxLine = 0
}
next := ((maxLine / 10) + 1) * 10
if next < 10 {
next = 10
}
return next, nil
}
func configurationLineOrderClause() string {
return "CASE WHEN COALESCE(local_configurations.line_no, 0) <= 0 THEN 2147483647 ELSE local_configurations.line_no END ASC, local_configurations.created_at DESC, local_configurations.id DESC"
}
// GetConfigurations returns all local configurations
func (l *LocalDB) GetConfigurations() ([]LocalConfiguration, error) {
var configs []LocalConfiguration

View File

@@ -108,6 +108,11 @@ var localMigrations = []localMigration{
name: "Deduplicate configuration revisions by spec+price",
run: deduplicateConfigurationVersionsBySpecAndPrice,
},
{
id: "2026_02_19_local_config_line_no",
name: "Add line_no to local_configurations and backfill ordering",
run: addLocalConfigurationLineNo,
},
}
func runLocalMigrations(db *gorm.DB) error {
@@ -806,3 +811,56 @@ func addLocalConfigurationSupportCode(tx *gorm.DB) error {
}
return nil
}
func addLocalConfigurationLineNo(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 ('line_no')
`).Scan(&columns).Error; err != nil {
return fmt.Errorf("check local_configurations(line_no) existence: %w", err)
}
if len(columns) == 0 {
if err := tx.Exec(`
ALTER TABLE local_configurations
ADD COLUMN line_no INTEGER
`).Error; err != nil {
return fmt.Errorf("add local_configurations.line_no: %w", err)
}
slog.Info("added line_no to local_configurations")
}
if err := tx.Exec(`
WITH ranked AS (
SELECT
id,
ROW_NUMBER() OVER (
PARTITION BY COALESCE(NULLIF(TRIM(project_uuid), ''), '__NO_PROJECT__')
ORDER BY created_at ASC, id ASC
) AS rn
FROM local_configurations
WHERE line_no IS NULL OR line_no <= 0
)
UPDATE local_configurations
SET line_no = (
SELECT rn * 10
FROM ranked
WHERE ranked.id = local_configurations.id
)
WHERE id IN (SELECT id FROM ranked)
`).Error; err != nil {
return fmt.Errorf("backfill local_configurations.line_no: %w", err)
}
if err := tx.Exec(`
CREATE INDEX IF NOT EXISTS idx_local_configurations_project_line_no
ON local_configurations(project_uuid, line_no)
`).Error; err != nil {
return fmt.Errorf("ensure idx_local_configurations_project_line_no: %w", err)
}
return nil
}

View File

@@ -83,35 +83,36 @@ func (s *LocalStringList) Scan(value interface{}) error {
// LocalConfiguration stores configurations in local SQLite
type LocalConfiguration struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UUID string `gorm:"uniqueIndex;not null" json:"uuid"`
ServerID *uint `json:"server_id"` // ID on MariaDB server, NULL if local only
ProjectUUID *string `gorm:"index" json:"project_uuid,omitempty"`
CurrentVersionID *string `gorm:"index" json:"current_version_id,omitempty"`
IsActive bool `gorm:"default:true;index" json:"is_active"`
Name string `gorm:"not null" json:"name"`
Items LocalConfigItems `gorm:"type:text" json:"items"` // JSON stored as text in SQLite
TotalPrice *float64 `json:"total_price"`
CustomPrice *float64 `json:"custom_price"`
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"`
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UUID string `gorm:"uniqueIndex;not null" json:"uuid"`
ServerID *uint `json:"server_id"` // ID on MariaDB server, NULL if local only
ProjectUUID *string `gorm:"index" json:"project_uuid,omitempty"`
CurrentVersionID *string `gorm:"index" json:"current_version_id,omitempty"`
IsActive bool `gorm:"default:true;index" json:"is_active"`
Name string `gorm:"not null" json:"name"`
Items LocalConfigItems `gorm:"type:text" json:"items"` // JSON stored as text in SQLite
TotalPrice *float64 `json:"total_price"`
CustomPrice *float64 `json:"custom_price"`
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"`
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
SyncedAt *time.Time `json:"synced_at"`
SyncStatus string `gorm:"default:'local'" json:"sync_status"` // 'local', 'synced', 'modified'
OriginalUserID uint `json:"original_user_id"` // UserID from MariaDB for reference
OriginalUsername string `gorm:"not null;default:'';index" json:"original_username"`
CurrentVersion *LocalConfigurationVersion `gorm:"foreignKey:CurrentVersionID;references:ID" json:"current_version,omitempty"`
Versions []LocalConfigurationVersion `gorm:"foreignKey:ConfigurationUUID;references:UUID" json:"versions,omitempty"`
Line int `gorm:"column:line_no;index" json:"line"`
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
SyncedAt *time.Time `json:"synced_at"`
SyncStatus string `gorm:"default:'local'" json:"sync_status"` // 'local', 'synced', 'modified'
OriginalUserID uint `json:"original_user_id"` // UserID from MariaDB for reference
OriginalUsername string `gorm:"not null;default:'';index" json:"original_username"`
CurrentVersion *LocalConfigurationVersion `gorm:"foreignKey:CurrentVersionID;references:ID" json:"current_version,omitempty"`
Versions []LocalConfigurationVersion `gorm:"foreignKey:ConfigurationUUID;references:UUID" json:"versions,omitempty"`
}
func (LocalConfiguration) TableName() string {

View File

@@ -28,6 +28,7 @@ func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
"article": localCfg.Article,
"pricelist_id": localCfg.PricelistID,
"only_in_stock": localCfg.OnlyInStock,
"line": localCfg.Line,
"price_updated_at": localCfg.PriceUpdatedAt,
"created_at": localCfg.CreatedAt,
"updated_at": localCfg.UpdatedAt,
@@ -61,6 +62,7 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
Article string `json:"article"`
PricelistID *uint `json:"pricelist_id"`
OnlyInStock bool `json:"only_in_stock"`
Line int `json:"line"`
PriceUpdatedAt *time.Time `json:"price_updated_at"`
OriginalUserID uint `json:"original_user_id"`
OriginalUsername string `json:"original_username"`
@@ -90,6 +92,7 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
Article: snapshot.Article,
PricelistID: snapshot.PricelistID,
OnlyInStock: snapshot.OnlyInStock,
Line: snapshot.Line,
PriceUpdatedAt: snapshot.PriceUpdatedAt,
OriginalUserID: snapshot.OriginalUserID,
OriginalUsername: snapshot.OriginalUsername,

View File

@@ -61,6 +61,7 @@ type Configuration struct {
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"`
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
Line int `gorm:"column:line_no;index" json:"line"`
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
CurrentVersionNo int `gorm:"-" json:"current_version_no,omitempty"`

View File

@@ -1,6 +1,8 @@
package repository
import (
"strings"
"git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/gorm"
)
@@ -14,7 +16,13 @@ func NewConfigurationRepository(db *gorm.DB) *ConfigurationRepository {
}
func (r *ConfigurationRepository) Create(config *models.Configuration) error {
return r.db.Create(config).Error
if err := r.db.Create(config).Error; err != nil {
if isUnknownLineNoColumnError(err) {
return r.db.Omit("line_no").Create(config).Error
}
return err
}
return nil
}
func (r *ConfigurationRepository) GetByID(id uint) (*models.Configuration, error) {
@@ -36,7 +44,21 @@ func (r *ConfigurationRepository) GetByUUID(uuid string) (*models.Configuration,
}
func (r *ConfigurationRepository) Update(config *models.Configuration) error {
return r.db.Save(config).Error
if err := r.db.Save(config).Error; err != nil {
if isUnknownLineNoColumnError(err) {
return r.db.Omit("line_no").Save(config).Error
}
return err
}
return nil
}
func isUnknownLineNoColumnError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "unknown column 'line_no'") || strings.Contains(msg, "no column named line_no")
}
func (r *ConfigurationRepository) Delete(id uint) error {

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"math"
"sort"
"strings"
"time"
@@ -42,6 +43,7 @@ type ExportItem struct {
// ConfigExportBlock represents one configuration (server) in the export.
type ConfigExportBlock struct {
Article string
Line int
ServerCount int
UnitPrice float64 // sum of component prices for one server
Items []ExportItem
@@ -92,7 +94,10 @@ func (s *ExportService) ToCSV(w io.Writer, data *ProjectExportData) error {
}
for i, block := range data.Configs {
lineNo := (i + 1) * 10
lineNo := block.Line
if lineNo <= 0 {
lineNo = (i + 1) * 10
}
serverCount := block.ServerCount
if serverCount < 1 {
@@ -104,13 +109,13 @@ func (s *ExportService) ToCSV(w io.Writer, data *ProjectExportData) error {
// Server summary row
serverRow := []string{
fmt.Sprintf("%d", lineNo), // Line
"", // Type
block.Article, // p/n
"", // Description
"", // Qty (1 pcs.)
fmt.Sprintf("%d", serverCount), // Qty (total)
formatPriceInt(block.UnitPrice), // Price (1 pcs.)
formatPriceWithSpace(totalPrice), // Price (total)
"", // Type
block.Article, // p/n
"", // Description
"", // Qty (1 pcs.)
fmt.Sprintf("%d", serverCount), // Qty (total)
formatPriceInt(block.UnitPrice), // Price (1 pcs.)
formatPriceWithSpace(totalPrice), // Price (total)
}
if err := csvWriter.Write(serverRow); err != nil {
return fmt.Errorf("failed to write server row: %w", err)
@@ -124,14 +129,14 @@ func (s *ExportService) ToCSV(w io.Writer, data *ProjectExportData) error {
// Component rows
for _, item := range sortedItems {
componentRow := []string{
"", // Line
item.Category, // Type
item.LotName, // p/n
"", // Description
fmt.Sprintf("%d", item.Quantity), // Qty (1 pcs.)
"", // Qty (total)
formatPriceComma(item.UnitPrice), // Price (1 pcs.)
"", // Price (total)
"", // Line
item.Category, // Type
item.LotName, // p/n
"", // Description
fmt.Sprintf("%d", item.Quantity), // Qty (1 pcs.)
"", // Qty (total)
formatPriceComma(item.UnitPrice), // Price (1 pcs.)
"", // Price (total)
}
if err := csvWriter.Write(componentRow); err != nil {
return fmt.Errorf("failed to write component row: %w", err)
@@ -174,9 +179,30 @@ func (s *ExportService) ConfigToExportData(cfg *models.Configuration) *ProjectEx
// ProjectToExportData converts multiple configurations into ProjectExportData.
func (s *ExportService) ProjectToExportData(configs []models.Configuration) *ProjectExportData {
sortedConfigs := make([]models.Configuration, len(configs))
copy(sortedConfigs, configs)
sort.Slice(sortedConfigs, func(i, j int) bool {
leftLine := sortedConfigs[i].Line
rightLine := sortedConfigs[j].Line
if leftLine <= 0 {
leftLine = int(^uint(0) >> 1)
}
if rightLine <= 0 {
rightLine = int(^uint(0) >> 1)
}
if leftLine != rightLine {
return leftLine < rightLine
}
if !sortedConfigs[i].CreatedAt.Equal(sortedConfigs[j].CreatedAt) {
return sortedConfigs[i].CreatedAt.After(sortedConfigs[j].CreatedAt)
}
return sortedConfigs[i].UUID > sortedConfigs[j].UUID
})
blocks := make([]ConfigExportBlock, 0, len(configs))
for i := range configs {
blocks = append(blocks, s.buildExportBlock(&configs[i]))
for i := range sortedConfigs {
blocks = append(blocks, s.buildExportBlock(&sortedConfigs[i]))
}
return &ProjectExportData{
Configs: blocks,
@@ -214,6 +240,7 @@ func (s *ExportService) buildExportBlock(cfg *models.Configuration) ConfigExport
return ConfigExportBlock{
Article: cfg.Article,
Line: cfg.Line,
ServerCount: serverCount,
UnitPrice: unitTotal,
Items: items,

View File

@@ -8,6 +8,7 @@ import (
"time"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/models"
)
func newTestProjectData(items []ExportItem, article string, serverCount int) *ProjectExportData {
@@ -357,6 +358,51 @@ func TestToCSV_MultipleBlocks(t *testing.T) {
}
}
func TestProjectToExportData_SortsByLine(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil, nil)
configs := []models.Configuration{
{
UUID: "cfg-1",
Line: 30,
Article: "ART-30",
ServerCount: 1,
Items: models.ConfigItems{{LotName: "LOT-30", Quantity: 1, UnitPrice: 300}},
CreatedAt: time.Now().Add(-1 * time.Hour),
},
{
UUID: "cfg-2",
Line: 10,
Article: "ART-10",
ServerCount: 1,
Items: models.ConfigItems{{LotName: "LOT-10", Quantity: 1, UnitPrice: 100}},
CreatedAt: time.Now().Add(-2 * time.Hour),
},
{
UUID: "cfg-3",
Line: 20,
Article: "ART-20",
ServerCount: 1,
Items: models.ConfigItems{{LotName: "LOT-20", Quantity: 1, UnitPrice: 200}},
CreatedAt: time.Now().Add(-3 * time.Hour),
},
}
data := svc.ProjectToExportData(configs)
if len(data.Configs) != 3 {
t.Fatalf("expected 3 blocks, got %d", len(data.Configs))
}
if data.Configs[0].Article != "ART-10" || data.Configs[0].Line != 10 {
t.Fatalf("first block must be line 10, got article=%s line=%d", data.Configs[0].Article, data.Configs[0].Line)
}
if data.Configs[1].Article != "ART-20" || data.Configs[1].Line != 20 {
t.Fatalf("second block must be line 20, got article=%s line=%d", data.Configs[1].Article, data.Configs[1].Line)
}
if data.Configs[2].Article != "ART-30" || data.Configs[2].Line != 30 {
t.Fatalf("third block must be line 30, got article=%s line=%d", data.Configs[2].Article, data.Configs[2].Line)
}
}
func TestFormatPriceWithSpace(t *testing.T) {
tests := []struct {
input float64

View File

@@ -107,6 +107,7 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
if err := s.createWithVersion(localCfg, ownerUsername); err != nil {
return nil, fmt.Errorf("create configuration with version: %w", err)
}
cfg.Line = localCfg.Line
// Record usage stats
_ = s.quoteService.RecordUsage(req.Items)
@@ -325,6 +326,7 @@ func (s *LocalConfigurationService) CloneToProject(configUUID string, ownerUsern
if err := s.createWithVersion(localCfg, ownerUsername); err != nil {
return nil, fmt.Errorf("clone configuration with version: %w", err)
}
clone.Line = localCfg.Line
return clone, nil
}
@@ -640,6 +642,7 @@ func (s *LocalConfigurationService) CloneNoAuthToProjectFromVersion(configUUID s
if err := s.createWithVersion(localCfg, ownerUsername); err != nil {
return nil, fmt.Errorf("clone configuration without auth with version: %w", err)
}
clone.Line = localCfg.Line
return clone, nil
}
@@ -826,21 +829,13 @@ func (s *LocalConfigurationService) UpdateServerCount(configUUID string, serverC
return fmt.Errorf("save local configuration: %w", err)
}
// Use existing current version for the pending change
var version localdb.LocalConfigurationVersion
if localCfg.CurrentVersionID != nil && *localCfg.CurrentVersionID != "" {
if err := tx.Where("id = ?", *localCfg.CurrentVersionID).First(&version).Error; err != nil {
return fmt.Errorf("load current version: %w", err)
}
} else {
if err := tx.Where("configuration_uuid = ?", localCfg.UUID).
Order("version_no DESC").First(&version).Error; err != nil {
return fmt.Errorf("load latest version: %w", err)
}
version, err := s.loadVersionForPendingTx(tx, localCfg)
if err != nil {
return err
}
cfg = localdb.LocalToConfiguration(localCfg)
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "update", &version, ""); err != nil {
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "update", version, ""); err != nil {
return fmt.Errorf("enqueue server-count pending change: %w", err)
}
return nil
@@ -852,6 +847,99 @@ func (s *LocalConfigurationService) UpdateServerCount(configUUID string, serverC
return cfg, nil
}
func (s *LocalConfigurationService) ReorderProjectConfigurationsNoAuth(projectUUID string, orderedUUIDs []string) ([]models.Configuration, error) {
projectUUID = strings.TrimSpace(projectUUID)
if projectUUID == "" {
return nil, ErrProjectNotFound
}
if _, err := s.localDB.GetProjectByUUID(projectUUID); err != nil {
return nil, ErrProjectNotFound
}
if len(orderedUUIDs) == 0 {
return []models.Configuration{}, nil
}
seen := make(map[string]struct{}, len(orderedUUIDs))
normalized := make([]string, 0, len(orderedUUIDs))
for _, raw := range orderedUUIDs {
u := strings.TrimSpace(raw)
if u == "" {
return nil, fmt.Errorf("ordered_uuids contains empty uuid")
}
if _, exists := seen[u]; exists {
return nil, fmt.Errorf("ordered_uuids contains duplicate uuid: %s", u)
}
seen[u] = struct{}{}
normalized = append(normalized, u)
}
err := s.localDB.DB().Transaction(func(tx *gorm.DB) error {
var active []localdb.LocalConfiguration
if err := tx.Where("project_uuid = ? AND is_active = ?", projectUUID, true).
Find(&active).Error; err != nil {
return fmt.Errorf("load project active configurations: %w", err)
}
if len(active) != len(normalized) {
return fmt.Errorf("ordered_uuids count mismatch: expected %d got %d", len(active), len(normalized))
}
byUUID := make(map[string]*localdb.LocalConfiguration, len(active))
for i := range active {
cfg := active[i]
byUUID[cfg.UUID] = &cfg
}
for _, id := range normalized {
if _, ok := byUUID[id]; !ok {
return fmt.Errorf("configuration %s not found in project %s", id, projectUUID)
}
}
now := time.Now()
for idx, id := range normalized {
cfg := byUUID[id]
newLine := (idx + 1) * 10
if cfg.Line == newLine {
continue
}
cfg.Line = newLine
cfg.UpdatedAt = now
cfg.SyncStatus = "pending"
if err := tx.Save(cfg).Error; err != nil {
return fmt.Errorf("save reordered configuration %s: %w", cfg.UUID, err)
}
version, err := s.loadVersionForPendingTx(tx, cfg)
if err != nil {
return err
}
if err := s.enqueueConfigurationPendingChangeTx(tx, cfg, "update", version, ""); err != nil {
return fmt.Errorf("enqueue reorder pending change for %s: %w", cfg.UUID, err)
}
}
return nil
})
if err != nil {
return nil, err
}
var localConfigs []localdb.LocalConfiguration
if err := s.localDB.DB().
Preload("CurrentVersion").
Where("project_uuid = ? AND is_active = ?", projectUUID, true).
Order("CASE WHEN COALESCE(line_no, 0) <= 0 THEN 2147483647 ELSE line_no END ASC, created_at DESC, id DESC").
Find(&localConfigs).Error; err != nil {
return nil, fmt.Errorf("load reordered configurations: %w", err)
}
result := make([]models.Configuration, 0, len(localConfigs))
for i := range localConfigs {
result = append(result, *localdb.LocalToConfiguration(&localConfigs[i]))
}
return result, nil
}
// ImportFromServer imports configurations from MariaDB to local SQLite cache.
func (s *LocalConfigurationService) ImportFromServer() (*sync.ConfigImportResult, error) {
return s.syncService.ImportConfigurationsToLocal()
@@ -965,6 +1053,11 @@ func (s *LocalConfigurationService) isOwner(cfg *localdb.LocalConfiguration, own
func (s *LocalConfigurationService) createWithVersion(localCfg *localdb.LocalConfiguration, createdBy string) error {
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
if localCfg.IsActive && localCfg.Line <= 0 {
if err := s.ensureConfigurationLineTx(tx, localCfg); err != nil {
return err
}
}
if err := tx.Create(localCfg).Error; err != nil {
return fmt.Errorf("create local configuration: %w", err)
}
@@ -1021,12 +1114,31 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
return fmt.Errorf("compare revision content: %w", err)
}
if sameRevisionContent {
cfg = localdb.LocalToConfiguration(&locked)
if !hasNonRevisionConfigurationChanges(&locked, localCfg) {
cfg = localdb.LocalToConfiguration(&locked)
return nil
}
if err := tx.Save(localCfg).Error; err != nil {
return fmt.Errorf("save local configuration (no new revision): %w", err)
}
cfg = localdb.LocalToConfiguration(localCfg)
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, operation, currentVersion, createdBy); err != nil {
return fmt.Errorf("enqueue %s pending change without revision: %w", operation, err)
}
if err := s.recalculateLocalPricelistUsageTx(tx); err != nil {
return fmt.Errorf("recalculate local pricelist usage: %w", err)
}
return nil
}
}
}
if localCfg.IsActive && localCfg.Line <= 0 {
if err := s.ensureConfigurationLineTx(tx, localCfg); err != nil {
return err
}
}
if err := tx.Save(localCfg).Error; err != nil {
return fmt.Errorf("save local configuration: %w", err)
}
@@ -1061,6 +1173,50 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
return cfg, nil
}
func hasNonRevisionConfigurationChanges(current *localdb.LocalConfiguration, next *localdb.LocalConfiguration) bool {
if current == nil || next == nil {
return true
}
if current.Name != next.Name ||
current.Notes != next.Notes ||
current.IsTemplate != next.IsTemplate ||
current.ServerModel != next.ServerModel ||
current.SupportCode != next.SupportCode ||
current.Article != next.Article ||
current.OnlyInStock != next.OnlyInStock ||
current.IsActive != next.IsActive ||
current.Line != next.Line {
return true
}
if !equalUintPtr(current.PricelistID, next.PricelistID) ||
!equalUintPtr(current.WarehousePricelistID, next.WarehousePricelistID) ||
!equalUintPtr(current.CompetitorPricelistID, next.CompetitorPricelistID) ||
!equalStringPtr(current.ProjectUUID, next.ProjectUUID) {
return true
}
return false
}
func equalStringPtr(a, b *string) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
return strings.TrimSpace(*a) == strings.TrimSpace(*b)
}
func equalUintPtr(a, b *uint) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
return *a == *b
}
func (s *LocalConfigurationService) loadCurrentVersionTx(tx *gorm.DB, localCfg *localdb.LocalConfiguration) (*localdb.LocalConfigurationVersion, error) {
var version localdb.LocalConfigurationVersion
if localCfg.CurrentVersionID != nil && *localCfg.CurrentVersionID != "" {
@@ -1082,6 +1238,32 @@ func (s *LocalConfigurationService) loadCurrentVersionTx(tx *gorm.DB, localCfg *
return &version, nil
}
func (s *LocalConfigurationService) loadVersionForPendingTx(tx *gorm.DB, localCfg *localdb.LocalConfiguration) (*localdb.LocalConfigurationVersion, error) {
if localCfg.CurrentVersionID != nil && *localCfg.CurrentVersionID != "" {
var current localdb.LocalConfigurationVersion
if err := tx.Where("id = ?", *localCfg.CurrentVersionID).First(&current).Error; err == nil {
return &current, nil
}
}
var latest localdb.LocalConfigurationVersion
if err := tx.Where("configuration_uuid = ?", localCfg.UUID).
Order("version_no DESC").
First(&latest).Error; err != nil {
return nil, fmt.Errorf("load version for pending change: %w", err)
}
return &latest, nil
}
func (s *LocalConfigurationService) ensureConfigurationLineTx(tx *gorm.DB, localCfg *localdb.LocalConfiguration) error {
line, err := localdb.NextConfigurationLineTx(tx, localCfg.ProjectUUID, localCfg.UUID)
if err != nil {
return fmt.Errorf("assign line_no for configuration %s: %w", localCfg.UUID, err)
}
localCfg.Line = line
return nil
}
func (s *LocalConfigurationService) hasSameRevisionContent(localCfg *localdb.LocalConfiguration, currentVersion *localdb.LocalConfigurationVersion) (bool, error) {
currentSnapshotCfg, err := s.decodeConfigurationSnapshot(currentVersion.Data)
if err != nil {
@@ -1195,6 +1377,9 @@ func (s *LocalConfigurationService) rollbackToVersion(configurationUUID string,
current.ServerCount = rollbackData.ServerCount
current.PricelistID = rollbackData.PricelistID
current.OnlyInStock = rollbackData.OnlyInStock
if rollbackData.Line > 0 {
current.Line = rollbackData.Line
}
current.PriceUpdatedAt = rollbackData.PriceUpdatedAt
current.UpdatedAt = time.Now()
current.SyncStatus = "pending"

View File

@@ -137,6 +137,78 @@ func TestUpdateNoAuthSkipsRevisionWhenSpecAndPriceUnchanged(t *testing.T) {
}
}
func TestReorderProjectConfigurationsDoesNotCreateNewVersions(t *testing.T) {
service, local := newLocalConfigServiceForTest(t)
project := &localdb.LocalProject{
UUID: "project-reorder",
OwnerUsername: "tester",
Code: "PRJ-ORDER",
Variant: "",
Name: ptrString("Project Reorder"),
IsActive: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
SyncStatus: "pending",
}
if err := local.SaveProject(project); err != nil {
t.Fatalf("save project: %v", err)
}
first, err := service.Create("tester", &CreateConfigRequest{
Name: "Cfg A",
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 100}},
ServerCount: 1,
ProjectUUID: &project.UUID,
})
if err != nil {
t.Fatalf("create first config: %v", err)
}
second, err := service.Create("tester", &CreateConfigRequest{
Name: "Cfg B",
Items: models.ConfigItems{{LotName: "CPU_B", Quantity: 1, UnitPrice: 200}},
ServerCount: 1,
ProjectUUID: &project.UUID,
})
if err != nil {
t.Fatalf("create second config: %v", err)
}
beforeFirst := loadVersions(t, local, first.UUID)
beforeSecond := loadVersions(t, local, second.UUID)
reordered, err := service.ReorderProjectConfigurationsNoAuth(project.UUID, []string{second.UUID, first.UUID})
if err != nil {
t.Fatalf("reorder configurations: %v", err)
}
if len(reordered) != 2 {
t.Fatalf("expected 2 reordered configs, got %d", len(reordered))
}
if reordered[0].UUID != second.UUID || reordered[0].Line != 10 {
t.Fatalf("expected second config first with line 10, got uuid=%s line=%d", reordered[0].UUID, reordered[0].Line)
}
if reordered[1].UUID != first.UUID || reordered[1].Line != 20 {
t.Fatalf("expected first config second with line 20, got uuid=%s line=%d", reordered[1].UUID, reordered[1].Line)
}
afterFirst := loadVersions(t, local, first.UUID)
afterSecond := loadVersions(t, local, second.UUID)
if len(afterFirst) != len(beforeFirst) || len(afterSecond) != len(beforeSecond) {
t.Fatalf("reorder must not create new versions")
}
var pendingCount int64
if err := local.DB().
Table("pending_changes").
Where("entity_type = ? AND operation = ? AND entity_uuid IN ?", "configuration", "update", []string{first.UUID, second.UUID}).
Count(&pendingCount).Error; err != nil {
t.Fatalf("count reorder pending changes: %v", err)
}
if pendingCount < 2 {
t.Fatalf("expected at least 2 pending update changes for reorder, got %d", pendingCount)
}
}
func TestAppendOnlyInvariantOldRowsUnchanged(t *testing.T) {
service, local := newLocalConfigServiceForTest(t)

View File

@@ -16,10 +16,10 @@ import (
)
var (
ErrProjectNotFound = errors.New("project not found")
ErrProjectForbidden = errors.New("access to project forbidden")
ErrProjectCodeExists = errors.New("project code and variant already exist")
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
ErrProjectNotFound = errors.New("project not found")
ErrProjectForbidden = errors.New("access to project forbidden")
ErrProjectCodeExists = errors.New("project code and variant already exist")
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
)
type ProjectService struct {
@@ -31,10 +31,10 @@ func NewProjectService(localDB *localdb.LocalDB) *ProjectService {
}
type CreateProjectRequest struct {
Code string `json:"code"`
Variant string `json:"variant,omitempty"`
Code string `json:"code"`
Variant string `json:"variant,omitempty"`
Name *string `json:"name,omitempty"`
TrackerURL string `json:"tracker_url"`
TrackerURL string `json:"tracker_url"`
}
type UpdateProjectRequest struct {
@@ -275,8 +275,23 @@ func (s *ProjectService) ListConfigurations(projectUUID, ownerUsername, status s
}, nil
}
query := s.localDB.DB().
Preload("CurrentVersion").
Where("project_uuid = ?", projectUUID).
Order("CASE WHEN COALESCE(line_no, 0) <= 0 THEN 2147483647 ELSE line_no END ASC, created_at DESC, id DESC")
switch status {
case "active", "":
query = query.Where("is_active = ?", true)
case "archived":
query = query.Where("is_active = ?", false)
case "all":
default:
query = query.Where("is_active = ?", true)
}
var localConfigs []localdb.LocalConfiguration
if err := s.localDB.DB().Preload("CurrentVersion").Order("created_at DESC").Find(&localConfigs).Error; err != nil {
if err := query.Find(&localConfigs).Error; err != nil {
return nil, err
}
@@ -284,25 +299,6 @@ func (s *ProjectService) ListConfigurations(projectUUID, ownerUsername, status s
total := 0.0
for i := range localConfigs {
localCfg := localConfigs[i]
if localCfg.ProjectUUID == nil || *localCfg.ProjectUUID != projectUUID {
continue
}
switch status {
case "active", "":
if !localCfg.IsActive {
continue
}
case "archived":
if localCfg.IsActive {
continue
}
case "all":
default:
if !localCfg.IsActive {
continue
}
}
cfg := localdb.LocalToConfiguration(&localCfg)
if cfg.TotalPrice != nil {
total += *cfg.TotalPrice

View File

@@ -145,6 +145,9 @@ func (s *Service) ImportConfigurationsToLocal() (*ConfigImportResult, error) {
if existing != nil && err == nil {
localCfg.ID = existing.ID
if localCfg.Line <= 0 && existing.Line > 0 {
localCfg.Line = existing.Line
}
result.Updated++
} else {
result.Imported++

View File

@@ -250,6 +250,71 @@ func TestPushPendingChangesCreateIsIdempotent(t *testing.T) {
}
}
func TestPushPendingChangesConfigurationPushesLine(t *testing.T) {
local := newLocalDBForSyncTest(t)
serverDB := newServerDBForSyncTest(t)
localSync := syncsvc.NewService(nil, local)
configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false })
pushService := syncsvc.NewServiceWithDB(serverDB, local)
created, err := configService.Create("tester", &services.CreateConfigRequest{
Name: "Cfg Line Push",
Items: models.ConfigItems{{LotName: "CPU_LINE", Quantity: 1, UnitPrice: 1000}},
ServerCount: 1,
})
if err != nil {
t.Fatalf("create config: %v", err)
}
if created.Line != 10 {
t.Fatalf("expected local create line=10, got %d", created.Line)
}
if _, err := pushService.PushPendingChanges(); err != nil {
t.Fatalf("push pending changes: %v", err)
}
var serverCfg models.Configuration
if err := serverDB.Where("uuid = ?", created.UUID).First(&serverCfg).Error; err != nil {
t.Fatalf("load server config: %v", err)
}
if serverCfg.Line != 10 {
t.Fatalf("expected server line=10 after push, got %d", serverCfg.Line)
}
}
func TestImportConfigurationsToLocalPullsLine(t *testing.T) {
local := newLocalDBForSyncTest(t)
serverDB := newServerDBForSyncTest(t)
cfg := models.Configuration{
UUID: "server-line-config",
OwnerUsername: "tester",
Name: "Cfg Line Pull",
Items: models.ConfigItems{{LotName: "CPU_PULL", Quantity: 1, UnitPrice: 900}},
ServerCount: 1,
Line: 40,
}
total := cfg.Items.Total()
cfg.TotalPrice = &total
if err := serverDB.Create(&cfg).Error; err != nil {
t.Fatalf("seed server config: %v", err)
}
svc := syncsvc.NewServiceWithDB(serverDB, local)
if _, err := svc.ImportConfigurationsToLocal(); err != nil {
t.Fatalf("import configurations to local: %v", err)
}
localCfg, err := local.GetConfigurationByUUID(cfg.UUID)
if err != nil {
t.Fatalf("load local config: %v", err)
}
if localCfg.Line != 40 {
t.Fatalf("expected imported line=40, got %d", localCfg.Line)
}
}
func TestPushPendingChangesCreateThenUpdateBeforeFirstPush(t *testing.T) {
local := newLocalDBForSyncTest(t)
serverDB := newServerDBForSyncTest(t)
@@ -361,6 +426,7 @@ CREATE TABLE qt_configurations (
competitor_pricelist_id INTEGER NULL,
disable_price_refresh INTEGER NOT NULL DEFAULT 0,
only_in_stock INTEGER NOT NULL DEFAULT 0,
line_no INTEGER NULL,
price_updated_at DATETIME NULL,
created_at DATETIME
);`).Error; err != nil {

View File

@@ -0,0 +1,18 @@
ALTER TABLE qt_configurations
ADD COLUMN IF NOT EXISTS line_no INT NULL AFTER only_in_stock;
UPDATE qt_configurations q
JOIN (
SELECT
id,
ROW_NUMBER() OVER (
PARTITION BY COALESCE(NULLIF(TRIM(project_uuid), ''), '__NO_PROJECT__')
ORDER BY created_at ASC, id ASC
) AS rn
FROM qt_configurations
WHERE line_no IS NULL OR line_no <= 0
) ranked ON ranked.id = q.id
SET q.line_no = ranked.rn * 10;
ALTER TABLE qt_configurations
ADD INDEX IF NOT EXISTS idx_qt_configurations_project_line_no (project_uuid, line_no);

View File

@@ -211,6 +211,8 @@ const projectUUID = '{{.ProjectUUID}}';
let configStatusMode = 'active';
let project = null;
let allConfigs = [];
let dragConfigUUID = '';
let isReorderingConfigs = false;
let projectVariants = [];
let projectsCatalog = [];
let variantMenuInitialized = false;
@@ -221,6 +223,11 @@ function escapeHtml(text) {
return div.innerHTML;
}
function formatMoneyNoDecimals(value) {
const safe = Number.isFinite(Number(value)) ? Number(value) : 0;
return '$' + Math.round(safe).toLocaleString('en-US');
}
function resolveProjectTrackerURL(projectData) {
if (!projectData) return '';
const explicitURL = (projectData.tracker_url || '').trim();
@@ -350,42 +357,53 @@ function renderConfigs(configs) {
let totalSum = 0;
let html = '<div class="bg-white rounded-lg shadow overflow-hidden"><table class="w-full">';
html += '<thead class="bg-gray-50"><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">Line</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-left text-xs font-medium text-gray-500 uppercase">Цена (за 1 шт)</th>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Цена за 1 шт.</th>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Кол-во</th>';
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Сумма</th>';
html += '<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">Ревизия</th>';
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Стоимость</th>';
html += '<th class="px-2 py-3 text-center text-xs font-medium text-gray-500 uppercase w-12"></th>';
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
html += '</tr></thead><tbody class="divide-y">';
html += '</tr></thead><tbody class="divide-y" id="project-configs-tbody">';
configs.forEach(c => {
const date = new Date(c.created_at).toLocaleDateString('ru-RU');
configs.forEach((c, idx) => {
const total = c.total_price || 0;
const serverCount = c.server_count || 1;
const author = c.owner_username || (c.user && c.user.username) || '—';
const unitPrice = serverCount > 0 ? (total / serverCount) : 0;
const lineValue = (typeof c.line === 'number' && c.line > 0) ? c.line : ((idx + 1) * 10);
const serverModel = (c.server_model || '').trim() || '—';
totalSum += total;
html += '<tr class="hover:bg-gray-50">';
html += '<td class="px-4 py-3 text-sm text-gray-500">' + date + '</td>';
const draggable = configStatusMode === 'active' ? 'true' : 'false';
html += '<tr class="hover:bg-gray-50" draggable="' + draggable + '" data-config-uuid="' + c.uuid + '" ondragstart="onConfigDragStart(event)" ondragover="onConfigDragOver(event)" ondragleave="onConfigDragLeave(event)" ondrop="onConfigDrop(event)" ondragend="onConfigDragEnd(event)">';
if (configStatusMode === 'active') {
html += '<td class="px-4 py-3 text-sm text-gray-500">';
html += '<span class="inline-flex items-center gap-2"><span class="drag-handle text-gray-400 hover:text-gray-700 cursor-grab active:cursor-grabbing select-none" title="Перетащить для изменения порядка" aria-label="Перетащить">';
html += '<svg class="w-4 h-4 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 6h.01M8 12h.01M8 18h.01M16 6h.01M16 12h.01M16 18h.01"></path></svg>';
html += '</span><span>' + lineValue + '</span></span></td>';
} else {
html += '<td class="px-4 py-3 text-sm text-gray-500">' + lineValue + '</td>';
}
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(serverModel) + '</td>';
if (configStatusMode === 'archived') {
html += '<td class="px-4 py-3 text-sm font-medium text-gray-700">' + escapeHtml(c.name) + '</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 text-gray-500">' + escapeHtml(author) + '</td>';
html += '<td class="px-4 py-3 text-sm text-gray-500">$' + unitPrice.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
html += '<td class="px-4 py-3 text-sm text-gray-500">' + formatMoneyNoDecimals(unitPrice) + '</td>';
if (configStatusMode === 'archived') {
html += '<td class="px-4 py-3 text-sm text-gray-500">' + serverCount + '</td>';
} else {
html += '<td class="px-4 py-3 text-sm text-gray-500"><input type="number" min="1" value="' + serverCount + '" class="w-16 px-1 py-0.5 border rounded text-center text-sm" data-uuid="' + c.uuid + '" data-prev="' + serverCount + '" onchange="updateConfigServerCount(this)"></td>';
}
html += '<td class="px-4 py-3 text-sm text-right" data-total-uuid="' + c.uuid + '">$' + total.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
html += '<td class="px-4 py-3 text-sm text-right" data-total-uuid="' + c.uuid + '">' + formatMoneyNoDecimals(total) + '</td>';
const versionNo = c.current_version_no || 1;
html += '<td class="px-4 py-3 text-sm text-center text-gray-500">v' + versionNo + '</td>';
html += '<td class="px-4 py-3 text-sm text-right space-x-2">';
html += '<td class="px-2 py-3 text-sm text-center text-gray-500 w-12">v' + versionNo + '</td>';
html += '<td class="px-4 py-3 text-sm text-right whitespace-nowrap"><div class="inline-flex items-center justify-end gap-2">';
if (configStatusMode === 'archived') {
html += '<button onclick="reactivateConfig(\'' + c.uuid + '\')" class="text-emerald-600 hover:text-emerald-800" title="Восстановить">';
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="M5 13l4 4L19 7"></path></svg></button>';
@@ -397,15 +415,16 @@ function renderConfigs(configs) {
html += '<button onclick="deleteConfig(\'' + c.uuid + '\')" class="text-red-600 hover:text-red-800" 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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg></button>';
}
html += '</td></tr>';
html += '</div></td></tr>';
});
html += '</tbody>';
html += '<tfoot class="bg-gray-50 border-t">';
html += '<tr>';
html += '<td class="px-4 py-3 text-sm font-medium text-gray-700" colspan="4">Итого по проекту</td>';
html += '<td class="px-4 py-3 text-sm font-medium text-gray-700" colspan="5">Итого по проекту</td>';
html += '<td class="px-4 py-3 text-sm text-gray-700">' + configs.length + '</td>';
html += '<td class="px-4 py-3 text-sm text-right font-semibold text-gray-900" data-footer-total="1">$' + totalSum.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
html += '<td class="px-4 py-3 text-sm text-right font-semibold text-gray-900" data-footer-total="1">' + formatMoneyNoDecimals(totalSum) + '</td>';
html += '<td class="px-4 py-3"></td>';
html += '<td class="px-4 py-3"></td>';
html += '<td class="px-4 py-3"></td>';
html += '</tr>';
@@ -994,6 +1013,105 @@ document.addEventListener('keydown', function(e) {
}
});
function onConfigDragStart(event) {
if (configStatusMode !== 'active' || isReorderingConfigs) {
event.preventDefault();
return;
}
const row = event.target.closest('tr[data-config-uuid]');
if (!row) {
event.preventDefault();
return;
}
dragConfigUUID = row.dataset.configUuid || '';
if (!dragConfigUUID) {
event.preventDefault();
return;
}
row.classList.add('opacity-50');
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', dragConfigUUID);
}
function onConfigDragOver(event) {
if (!dragConfigUUID || configStatusMode !== 'active') return;
event.preventDefault();
const row = event.target.closest('tr[data-config-uuid]');
if (!row || row.dataset.configUuid === dragConfigUUID) return;
row.classList.add('ring-2', 'ring-blue-300');
}
function onConfigDragLeave(event) {
const row = event.target.closest('tr[data-config-uuid]');
if (!row) return;
row.classList.remove('ring-2', 'ring-blue-300');
}
async function onConfigDrop(event) {
if (!dragConfigUUID || configStatusMode !== 'active' || isReorderingConfigs) return;
event.preventDefault();
const targetRow = event.target.closest('tr[data-config-uuid]');
if (!targetRow) return;
targetRow.classList.remove('ring-2', 'ring-blue-300');
const targetUUID = targetRow.dataset.configUuid || '';
if (!targetUUID || targetUUID === dragConfigUUID) return;
const previous = allConfigs.slice();
const fromIndex = allConfigs.findIndex(c => c.uuid === dragConfigUUID);
const toIndex = allConfigs.findIndex(c => c.uuid === targetUUID);
if (fromIndex < 0 || toIndex < 0) return;
const [moved] = allConfigs.splice(fromIndex, 1);
allConfigs.splice(toIndex, 0, moved);
renderConfigs(allConfigs);
await saveConfigReorder(previous);
}
function onConfigDragEnd() {
document.querySelectorAll('tr[data-config-uuid]').forEach(row => {
row.classList.remove('ring-2', 'ring-blue-300', 'opacity-50');
});
dragConfigUUID = '';
}
async function saveConfigReorder(previousConfigs) {
if (isReorderingConfigs) return;
isReorderingConfigs = true;
const orderedUUIDs = allConfigs.map(c => c.uuid);
try {
const resp = await fetch('/api/projects/' + projectUUID + '/configs/reorder', {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ordered_uuids: orderedUUIDs}),
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || 'Не удалось сохранить порядок');
}
const data = await resp.json();
allConfigs = data.configurations || allConfigs;
renderConfigs(allConfigs);
if (typeof showToast === 'function') {
showToast('Порядок сохранён', 'success');
}
} catch (e) {
allConfigs = previousConfigs.slice();
renderConfigs(allConfigs);
if (typeof showToast === 'function') {
showToast(e.message || 'Не удалось сохранить порядок', 'error');
} else {
alert(e.message || 'Не удалось сохранить порядок');
}
} finally {
isReorderingConfigs = false;
dragConfigUUID = '';
}
}
async function updateConfigServerCount(input) {
const uuid = input.dataset.uuid;
const prevValue = parseInt(input.dataset.prev) || 1;
@@ -1018,7 +1136,7 @@ async function updateConfigServerCount(input) {
// Update row total price
const totalCell = document.querySelector('[data-total-uuid="' + uuid + '"]');
if (totalCell && updated.total_price != null) {
totalCell.textContent = '$' + updated.total_price.toLocaleString('en-US', {minimumFractionDigits: 2});
totalCell.textContent = formatMoneyNoDecimals(updated.total_price);
}
// Update the config in allConfigs and recalculate footer total
for (let i = 0; i < allConfigs.length; i++) {
@@ -1040,7 +1158,7 @@ function updateFooterTotal() {
allConfigs.forEach(c => { totalSum += (c.total_price || 0); });
const footer = document.querySelector('tfoot td[data-footer-total]');
if (footer) {
footer.textContent = '$' + totalSum.toLocaleString('en-US', {minimumFractionDigits: 2});
footer.textContent = formatMoneyNoDecimals(totalSum);
}
}