Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b2dc6652a | |||
| cea979e327 | |||
| 4d002671ae | |||
| 949479550c | |||
|
|
677b5d898f | ||
|
|
b3cab3477b |
2
bible
2
bible
Submodule bible updated: 52444350c1...1977730d93
@@ -894,7 +894,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
router.GET("/pricelists/:id", webHandler.PricelistDetail)
|
||||
router.GET("/partnumber-books", webHandler.PartnumberBooks)
|
||||
|
||||
// Short project URL: /:code → redirect to /projects/:uuid
|
||||
// Short project URLs: /:code → main variant, /:code/:variant → named variant
|
||||
router.GET("/:code", func(c *gin.Context) {
|
||||
code := c.Param("code")
|
||||
project, err := projectService.GetByCode(code)
|
||||
@@ -904,6 +904,16 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/projects/"+project.UUID)
|
||||
})
|
||||
router.GET("/:code/:variant", func(c *gin.Context) {
|
||||
code := c.Param("code")
|
||||
variant := c.Param("variant")
|
||||
project, err := projectService.GetByCodeAndVariant(code, variant)
|
||||
if err != nil {
|
||||
c.Redirect(http.StatusFound, "/projects")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/projects/"+project.UUID)
|
||||
})
|
||||
|
||||
// htmx partials
|
||||
partials := router.Group("/partials")
|
||||
@@ -1538,7 +1548,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrReservedMainVariant),
|
||||
errors.Is(err, services.ErrProjectCodeInvalidChars):
|
||||
errors.Is(err, services.ErrProjectCodeInvalidChars),
|
||||
errors.Is(err, services.ErrProjectVariantInvalidChars):
|
||||
respondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
case errors.Is(err, services.ErrProjectCodeExists):
|
||||
respondError(c, http.StatusConflict, "conflict detected", err)
|
||||
@@ -1577,7 +1588,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
switch {
|
||||
case errors.Is(err, services.ErrReservedMainVariant),
|
||||
errors.Is(err, services.ErrCannotRenameMainVariant),
|
||||
errors.Is(err, services.ErrProjectCodeInvalidChars):
|
||||
errors.Is(err, services.ErrProjectCodeInvalidChars),
|
||||
errors.Is(err, services.ErrProjectVariantInvalidChars):
|
||||
respondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
case errors.Is(err, services.ErrProjectCodeExists):
|
||||
respondError(c, http.StatusConflict, "conflict detected", err)
|
||||
|
||||
@@ -700,6 +700,14 @@ func (l *LocalDB) GetProjectByCode(code string) (*LocalProject, error) {
|
||||
return &project, nil
|
||||
}
|
||||
|
||||
func (l *LocalDB) GetProjectByCodeAndVariant(code, variant string) (*LocalProject, error) {
|
||||
var project LocalProject
|
||||
if err := l.db.Where("LOWER(code) = LOWER(?) AND LOWER(variant) = LOWER(?)", code, variant).First(&project).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &project, nil
|
||||
}
|
||||
|
||||
func (l *LocalDB) GetProjectByName(ownerUsername, name string) (*LocalProject, error) {
|
||||
var project LocalProject
|
||||
if err := l.db.Where("owner_username = ? AND name = ?", ownerUsername, name).First(&project).Error; err != nil {
|
||||
|
||||
@@ -17,13 +17,14 @@ 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")
|
||||
ErrReservedMainVariant = errors.New("variant name 'main' is reserved")
|
||||
ErrCannotRenameMainVariant = errors.New("cannot rename main variant")
|
||||
ErrProjectCodeInvalidChars = errors.New("код опти содержит недопустимые символы (разрешены: буквы, цифры, дефис, точка, подчёркивание)")
|
||||
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")
|
||||
ErrReservedMainVariant = errors.New("variant name 'main' is reserved")
|
||||
ErrCannotRenameMainVariant = errors.New("cannot rename main variant")
|
||||
ErrProjectCodeInvalidChars = errors.New("код опти содержит недопустимые символы (разрешены: буквы, цифры, дефис, точка, подчёркивание)")
|
||||
ErrProjectVariantInvalidChars = errors.New("имя варианта содержит недопустимые символы (разрешены: буквы, цифры, дефис, точка, подчёркивание)")
|
||||
)
|
||||
|
||||
// projectCodeRe allows only URL-path-safe characters so project codes can appear directly in URLs.
|
||||
@@ -194,6 +195,9 @@ func validateProjectVariantName(variant string) error {
|
||||
if normalizeProjectVariant(variant) == "main" {
|
||||
return ErrReservedMainVariant
|
||||
}
|
||||
if variant != "" && !projectCodeRe.MatchString(variant) {
|
||||
return ErrProjectVariantInvalidChars
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -302,6 +306,15 @@ func (s *ProjectService) GetByCode(code string) (*models.Project, error) {
|
||||
return localdb.LocalToProject(localProject), nil
|
||||
}
|
||||
|
||||
// GetByCodeAndVariant finds a project by code + variant (both case-insensitive).
|
||||
func (s *ProjectService) GetByCodeAndVariant(code, variant string) (*models.Project, error) {
|
||||
localProject, err := s.localDB.GetProjectByCodeAndVariant(code, variant)
|
||||
if err != nil {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
return localdb.LocalToProject(localProject), nil
|
||||
}
|
||||
|
||||
func (s *ProjectService) ListConfigurations(projectUUID, ownerUsername, status string) (*ProjectConfigurationsResult, error) {
|
||||
project, err := s.GetByUUID(projectUUID, ownerUsername)
|
||||
if err != nil {
|
||||
|
||||
@@ -322,6 +322,12 @@ func (s *Service) NeedSync() (bool, error) {
|
||||
|
||||
// SyncPricelists synchronizes all active pricelists from server to local SQLite
|
||||
func (s *Service) SyncPricelists() (int, error) {
|
||||
s.pricelistMu.Lock()
|
||||
defer s.pricelistMu.Unlock()
|
||||
return s.syncPricelists()
|
||||
}
|
||||
|
||||
func (s *Service) syncPricelists() (int, error) {
|
||||
slog.Info("starting pricelist sync")
|
||||
plSyncStart := time.Now()
|
||||
if _, err := s.EnsureReadinessForSync(); err != nil {
|
||||
@@ -336,6 +342,12 @@ func (s *Service) SyncPricelists() (int, error) {
|
||||
return 0, fmt.Errorf("database not available: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if reportErr := s.reportClientSchemaState(mariaDB, time.Now().UTC()); reportErr != nil {
|
||||
slog.Warn("failed to report client state after pricelist sync", "error", reportErr)
|
||||
}
|
||||
}()
|
||||
|
||||
// Create repository
|
||||
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
||||
|
||||
@@ -764,9 +776,16 @@ func (s *Service) fetchServerPricelistItems(serverPricelistID uint) ([]localdb.L
|
||||
return nil, fmt.Errorf("getting server pricelist items: %w", err)
|
||||
}
|
||||
|
||||
localItems := make([]localdb.LocalPricelistItem, len(serverItems))
|
||||
for i, item := range serverItems {
|
||||
localItems[i] = *localdb.PricelistItemToLocal(&item, 0)
|
||||
seen := make(map[string]struct{}, len(serverItems))
|
||||
localItems := make([]localdb.LocalPricelistItem, 0, len(serverItems))
|
||||
for i := range serverItems {
|
||||
lotName := serverItems[i].LotName
|
||||
if _, dup := seen[lotName]; dup {
|
||||
slog.Warn("duplicate lot_name in server pricelist, skipping", "pricelist_id", serverPricelistID, "lot_name", lotName)
|
||||
continue
|
||||
}
|
||||
seen[lotName] = struct{}{}
|
||||
localItems = append(localItems, *localdb.PricelistItemToLocal(&serverItems[i], 0))
|
||||
}
|
||||
|
||||
return localItems, nil
|
||||
@@ -843,7 +862,7 @@ func (s *Service) SyncPricelistsIfNeeded() error {
|
||||
}
|
||||
|
||||
slog.Info("new pricelists detected, syncing...")
|
||||
_, err = s.SyncPricelists()
|
||||
_, err = s.syncPricelists()
|
||||
if err != nil {
|
||||
return fmt.Errorf("syncing pricelists: %w", err)
|
||||
}
|
||||
@@ -888,7 +907,10 @@ func (s *Service) PushPendingChanges() (int, error) {
|
||||
}
|
||||
|
||||
slog.Info("pushing pending changes", "count", len(changes))
|
||||
pushStart := time.Now()
|
||||
pushed := 0
|
||||
failed := 0
|
||||
var firstErr string
|
||||
var syncedIDs []int64
|
||||
sortedChanges := prioritizeProjectChanges(changes)
|
||||
|
||||
@@ -899,6 +921,10 @@ func (s *Service) PushPendingChanges() (int, error) {
|
||||
slog.Warn("failed to push change", "id", change.ID, "type", change.EntityType, "operation", change.Operation, "error", err)
|
||||
newAttempts := change.Attempts + 1
|
||||
s.localDB.IncrementPendingChangeAttempts(change.ID, err.Error())
|
||||
if firstErr == "" {
|
||||
firstErr = err.Error()
|
||||
}
|
||||
failed++
|
||||
if newAttempts >= maxPendingChangeAttempts {
|
||||
slog.Error("abandoning pending change after max attempts",
|
||||
"id", change.ID, "type", change.EntityType, "op", change.Operation,
|
||||
@@ -919,7 +945,13 @@ func (s *Service) PushPendingChanges() (int, error) {
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("pending changes pushed", "pushed", pushed, "failed", len(changes)-pushed)
|
||||
if failed > 0 {
|
||||
s.localDB.AppendSyncLog("changes", "error", firstErr, pushed, pushStart, time.Since(pushStart).Milliseconds())
|
||||
} else {
|
||||
s.localDB.AppendSyncLog("changes", "ok", "", pushed, pushStart, time.Since(pushStart).Milliseconds())
|
||||
}
|
||||
|
||||
slog.Info("pending changes pushed", "pushed", pushed, "failed", failed)
|
||||
return pushed, nil
|
||||
}
|
||||
|
||||
|
||||
29
releases/v2.21/RELEASE_NOTES.md
Normal file
29
releases/v2.21/RELEASE_NOTES.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# QuoteForge v2.21
|
||||
|
||||
Дата релиза: 2026-06-25
|
||||
Тег: `v2.21`
|
||||
|
||||
## Что нового
|
||||
|
||||
### Короткие ссылки на проекты и варианты
|
||||
|
||||
- `GET /:code` — редирект на проект по коду опти (регистронезависимо);
|
||||
- `GET /:code/:variant` — редирект на конкретный вариант проекта;
|
||||
- валидация кода опти и имени варианта: только URL-безопасные символы `[A-Za-z0-9._-]` — проверка на бэкенде и в форме с подсказкой `«Используется в URL: /КОД/Вариант»`.
|
||||
|
||||
### Ревизия «до обновления цен»
|
||||
|
||||
При нажатии «Обновить цены» автоматически создаётся ревизия текущего состояния конфигурации до применения новых цен, после чего сохраняется ревизия с обновлёнными ценами. История изменений теперь полная.
|
||||
|
||||
### Исправления
|
||||
|
||||
- Старая цена в итоге конфигурации больше не зачёркивается, если цены фактически не изменились.
|
||||
- Устранён race condition: `SyncPricelists()` теперь защищена мьютексом — параллельный запуск фонового тикера и ручной синхронизации больше не приводит к `UNIQUE constraint failed`.
|
||||
- Дублирующиеся `lot_name` в серверном прайслисте пропускаются при загрузке вместо аварийного завершения синхронизации.
|
||||
- Ошибки отправки конфигураций и проектов на сервер теперь видны в диалоге «Информация о синхронизации» и в support bundle (`sync_log`, тип `changes`).
|
||||
- Состояние клиента (`last_sync_error_code` и др.) отправляется на сервер по завершении синхронизации независимо от её результата.
|
||||
|
||||
## Запуск на macOS
|
||||
|
||||
Снимите карантинный атрибут через терминал: `xattr -d com.apple.quarantine /path/to/qfs-darwin-arm64`
|
||||
После этого бинарник запустится без предупреждения Gatekeeper.
|
||||
@@ -629,11 +629,13 @@
|
||||
|
||||
const totalColor = totalDelta > 0 ? 'text-red-600' : totalDelta < 0 ? 'text-green-600' : 'text-gray-600';
|
||||
const totalArrow = _fmtArrow(r.prevTotal || 0, r.newTotal || 0);
|
||||
const totalPrevHtml = totalDelta !== 0
|
||||
? `<span class="text-gray-400 line-through text-xs mr-1">${_fmtMoneyDiff(r.prevTotal || 0)}</span>`
|
||||
: '';
|
||||
html += `<div class="flex justify-between items-center text-sm bg-gray-50 rounded px-3 py-2 mb-1">
|
||||
<span class="text-gray-600 font-medium">Итог конфигурации</span>
|
||||
<span>
|
||||
<span class="text-gray-400 line-through text-xs mr-1">${_fmtMoneyDiff(r.prevTotal || 0)}</span>
|
||||
<span class="${totalColor} font-semibold">${_fmtMoneyDiff(r.newTotal || 0)}</span>${totalArrow}
|
||||
${totalPrevHtml}<span class="${totalColor} font-semibold">${_fmtMoneyDiff(r.newTotal || 0)}</span>${totalArrow}
|
||||
</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -207,9 +207,11 @@
|
||||
</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"
|
||||
<input id="new-variant-value" type="text" placeholder="Например: B200"
|
||||
pattern="[A-Za-z0-9._-]+"
|
||||
title="Только буквы, цифры, дефис, точка, подчёркивание"
|
||||
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 class="text-xs text-gray-500 mt-1">Буквы, цифры, дефис, точка, подчёркивание. Используется в URL: /КОД/Вариант.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end gap-2">
|
||||
@@ -842,6 +844,10 @@ async function createNewVariant() {
|
||||
showToast('Укажите вариант', 'error');
|
||||
return;
|
||||
}
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(variant)) {
|
||||
showToast('Имя варианта содержит недопустимые символы. Разрешены: буквы, цифры, дефис, точка, подчёркивание.', 'error');
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
code: code,
|
||||
variant: variant,
|
||||
|
||||
@@ -46,8 +46,11 @@
|
||||
</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"
|
||||
<input id="create-project-variant" type="text" placeholder="Например: B200"
|
||||
pattern="[A-Za-z0-9._-]*"
|
||||
title="Только буквы, цифры, дефис, точка, подчёркивание"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<p class="text-xs text-gray-400 mt-1">Используется в URL: /КОД/Вариант</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="create-project-tracker-url" class="block text-sm font-medium text-gray-700 mb-1">Ссылка на трекер</label>
|
||||
@@ -403,6 +406,10 @@ async function createProject() {
|
||||
alert('Код проекта содержит недопустимые символы.\nРазрешены: буквы, цифры, дефис, точка, подчёркивание.');
|
||||
return;
|
||||
}
|
||||
if (variant && !/^[A-Za-z0-9._-]+$/.test(variant)) {
|
||||
alert('Имя варианта содержит недопустимые символы.\nРазрешены: буквы, цифры, дефис, точка, подчёркивание.');
|
||||
return;
|
||||
}
|
||||
const resp = await fetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
|
||||
Reference in New Issue
Block a user