40 Commits

Author SHA1 Message Date
Mikhail Chusavitin
65871a8b04 WIP: save current pricing and pricelist changes 2026-02-06 19:07:22 +03:00
Mikhail Chusavitin
b27152b353 configs: save pending template changes 2026-02-06 16:43:04 +03:00
Mikhail Chusavitin
2e69089bd5 projects: add tracker_url and project create modal 2026-02-06 16:42:32 +03:00
Mikhail Chusavitin
be1c962fec Add tracker link on project detail page 2026-02-06 16:31:34 +03:00
Mikhail Chusavitin
57215cb7b3 Fix local pricelist uniqueness and preserve config project on update 2026-02-06 16:00:23 +03:00
Mikhail Chusavitin
31dce9c721 Make full sync push pending and pull projects/configurations 2026-02-06 15:25:07 +03:00
Mikhail Chusavitin
06d0e8b14b Prepare v1.0.3 release notes 2026-02-06 14:04:06 +03:00
Mikhail Chusavitin
b1b50ce2ef Add projects table controls and sync status tab with app version 2026-02-06 14:02:21 +03:00
Mikhail Chusavitin
6ab1e9899e sync: recover missing server config during update push 2026-02-06 13:41:01 +03:00
Mikhail Chusavitin
a1d21927a3 Fix MySQL DSN escaping for setup passwords and clarify DB user setup 2026-02-06 13:27:57 +03:00
Mikhail Chusavitin
a90c07c879 update stale files list 2026-02-06 13:03:59 +03:00
Mikhail Chusavitin
e9307c4bad Apply remaining pricelist and local-first updates 2026-02-06 13:01:40 +03:00
Mikhail Chusavitin
1b48401828 Use admin price-refresh logic for pricelist recalculation 2026-02-06 13:00:27 +03:00
Mikhail Chusavitin
4a86f7b7ba fix: skip startup sql migrations when not needed or no permissions 2026-02-06 11:56:55 +03:00
Mikhail Chusavitin
955467fbea feat: add projects flow and consolidate default project handling 2026-02-06 11:39:12 +03:00
Mikhail Chusavitin
9ddffe48e9 Update pricelist repository, service, and tests 2026-02-06 10:14:24 +03:00
Mikhail Chusavitin
4732605925 Enforce pricelist write checks and auto-restart on DB settings change 2026-02-05 15:44:54 +03:00
Mikhail Chusavitin
d318a7f462 Purge orphan sync queue entries before push 2026-02-05 15:17:06 +03:00
Mikhail Chusavitin
1bec110d91 Handle stale configuration sync events when local row is missing 2026-02-05 15:11:43 +03:00
Mikhail Chusavitin
6392e4b4a9 Drop qt_users dependency for configs and track app version 2026-02-05 15:07:23 +03:00
Mikhail Chusavitin
8f7defdb8a Добавил шаблон для создания пользователя в БД 2026-02-05 10:55:02 +03:00
Mikhail Chusavitin
0c190efda4 Fix sync owner mapping before pushing configurations 2026-02-05 10:43:34 +03:00
Mikhail Chusavitin
41c0a47f54 Implement local DB migrations and archived configuration lifecycle 2026-02-04 18:52:56 +03:00
Mikhail Chusavitin
f4f92dea66 Store configuration owner by MariaDB username 2026-02-04 12:20:41 +03:00
Mikhail Chusavitin
f42b850734 Recover DB connection automatically after network returns 2026-02-04 11:43:31 +03:00
Mikhail Chusavitin
d094d39427 Add server-to-local configuration import in web UI 2026-02-04 11:31:23 +03:00
Mikhail Chusavitin
4509e93864 Store config in user state and clean old release notes 2026-02-04 11:21:48 +03:00
Mikhail Chusavitin
e2800b06f9 Log binary version and executable path on startup 2026-02-04 10:21:18 +03:00
Mikhail Chusavitin
7c606af2bb Fix missing config handling and auto-restart after setup 2026-02-04 10:19:35 +03:00
Mikhail Chusavitin
fabd30650d Store local DB in user state dir as qfs.db 2026-02-04 10:03:17 +03:00
Mikhail Chusavitin
40ade651b0 Ignore local Go cache directory 2026-02-04 09:55:36 +03:00
Mikhail Chusavitin
1b87c53609 Fix offline usage tracking and active pricelist sync 2026-02-04 09:54:13 +03:00
a3dc264efd Merge feature/phase2-sqlite-sync into main 2026-02-03 22:04:17 +03:00
Mikhail Chusavitin
8a37542929 docs: add release notes for v0.2.7 2026-02-03 11:39:23 +03:00
Mikhail Chusavitin
0eb6730a55 fix: Windows compatibility and localhost binding
**Windows compatibility:**
- Added filepath.Join for all template and static paths
- Fixes "path not found" errors on Windows

**Localhost binding:**
- Changed default host from 0.0.0.0 to 127.0.0.1
- Browser always opens on 127.0.0.1 (localhost)
- Setup mode now listens on 127.0.0.1:8080
- Updated config.example.yaml with comment about 0.0.0.0

This ensures the app works correctly on Windows and opens
browser on the correct localhost address.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 11:38:28 +03:00
Mikhail Chusavitin
e2d056e7cb feat: add Windows support to build system
- Add make build-windows for Windows AMD64
- Update make build-all to include Windows
- Update release script to package Windows binary as .zip
- Add Windows installation instructions to docs
- Windows binary: qfs-windows-amd64.exe (~17MB)

All platforms now supported:
- Linux AMD64 (.tar.gz)
- macOS Intel/ARM (.tar.gz)
- Windows AMD64 (.zip)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 11:04:04 +03:00
Mikhail Chusavitin
1bce8086d6 feat: add release build script for multi-platform binaries
- Add scripts/release.sh for automated release builds
- Creates tar.gz packages for Linux and macOS
- Generates SHA256 checksums
- Add 'make release' target
- Add releases/ to .gitignore

Usage:
  make release  # Build and package for all platforms

Output: releases/v0.2.5/*.tar.gz + SHA256SUMS.txt

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 10:58:41 +03:00
Mikhail Chusavitin
0bdd163728 feat: add version flag and Makefile for release builds
- Add -version flag to show build version
- Add Makefile with build targets:
  - make build-release: optimized build with version
  - make build-all: cross-compile for Linux/macOS
  - make run/test/clean: dev commands
- Update documentation with build commands
- Version is embedded via ldflags during build

Usage:
  make build-release  # Build with version
  ./bin/qfs -version  # Show version

Version format: v0.2.5-1-gfa0f5e3 (tag-commits-hash)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 10:57:22 +03:00
Mikhail Chusavitin
fa0f5e321d refactor: rename binary from quoteforge to qfs
- Rename cmd/server to cmd/qfs for shorter binary name
- Update all documentation references (README, CLAUDE.md, etc.)
- Update build commands to output bin/qfs
- Binary name now matches directory name

Usage:
  go run ./cmd/qfs              # Development
  go build -o bin/qfs ./cmd/qfs # Production

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 10:55:14 +03:00
Mikhail Chusavitin
502832ac9a Merge feature/phase2-sqlite-sync into main
This merge brings Phase 2.5 (Full Offline Mode) with the following improvements:

- Local-first architecture: all operations work through SQLite
- Background sync worker for automatic synchronization
- Sync queue (pending_changes table) for reliable data push
- LocalConfigurationService for offline-capable CRUD operations
- Pre-create pricelist check before configuration creation
- RefreshPrices works in offline mode using local_components
- UI improvements: sync status indicator, pricelist badge, unified admin tabs
- Fixed online mode: automatic MariaDB connection on startup
- Fixed nil pointer dereference in PricingHandler alert methods
- Improved setup flow with restart requirement notification

Phase 2.5 is now complete. Ready for production.
2026-02-03 10:51:48 +03:00
77 changed files with 11337 additions and 1406 deletions

23
.gitignore vendored
View File

@@ -13,6 +13,28 @@ config.yaml
/cron
/bin/
# Local Go build cache used in sandboxed runs
.gocache/
# Local tooling state
.claude/
# Editor settings
.idea/
.vscode/
*.swp
*.swo
# Temp and logs
*.tmp
*.temp
*.log
# Go test/build artifacts
*.out
*.test
coverage/
# ---> macOS
# General
.DS_Store
@@ -41,3 +63,4 @@ Network Trash Folder
Temporary Items
.apdisk
releases/

View File

@@ -134,9 +134,22 @@ Go 1.22+ | Gin | GORM | MariaDB 11 | SQLite (glebarez/sqlite) | htmx + Tailwind
## Commands
```bash
go run ./cmd/server # Dev server
go run ./cmd/cron -job=X # cleanup-pricelists | update-prices | update-popularity
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge ./cmd/server
# Development
go run ./cmd/qfs # Dev server
make run # Dev server (via Makefile)
# Production build
make build-release # Optimized build with version (recommended)
VERSION=$(git describe --tags --always --dirty)
CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=$VERSION" -o bin/qfs ./cmd/qfs
# Cron jobs
go run ./cmd/cron -job=cleanup-pricelists # Remove old unused pricelists
go run ./cmd/cron -job=update-prices # Recalculate all prices
go run ./cmd/cron -job=update-popularity # Update popularity scores
# Check version
./bin/qfs -version
```
## Code Style

View File

@@ -60,7 +60,7 @@ localConfigService := services.NewLocalConfigurationService(
### Шаг 1: Обновить main.go
```go
// В cmd/server/main.go
// В cmd/qfs/main.go
syncService := sync.NewService(pricelistRepo, configRepo, local)
// Создать isOnline функцию
@@ -165,7 +165,7 @@ type PendingChange struct {
```bash
# Compile
go build ./cmd/server
go build ./cmd/qfs
# Run
./quoteforge

View File

@@ -89,7 +89,7 @@ mysql -u user -p RFQ_LOG < migrations/004_add_price_updated_at.sql
- `internal/models/configuration.go` - добавлено поле `PriceUpdatedAt`
- `internal/services/configuration.go` - добавлен метод `RefreshPrices()`
- `internal/handlers/configuration.go` - добавлен обработчик `RefreshPrices()`
- `cmd/server/main.go` - добавлен маршрут `/api/configs/:uuid/refresh-prices`
- `cmd/qfs/main.go` - добавлен маршрут `/api/configs/:uuid/refresh-prices`
- `web/templates/index.html` - добавлена кнопка и JavaScript функции
- `migrations/004_add_price_updated_at.sql` - SQL миграция
- `CLAUDE.md` - обновлена документация

97
Makefile Normal file
View File

@@ -0,0 +1,97 @@
.PHONY: build build-release clean test run version
# Get version from git
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
BUILD_TIME := $(shell date -u '+%Y-%m-%d_%H:%M:%S')
LDFLAGS := -s -w -X main.Version=$(VERSION)
# Binary name
BINARY := qfs
# Build for development (with debug info)
build:
go build -o bin/$(BINARY) ./cmd/qfs
# Build for release (optimized, with version)
build-release:
@echo "Building $(BINARY) version $(VERSION)..."
CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -o bin/$(BINARY) ./cmd/qfs
@echo "✓ Built: bin/$(BINARY)"
@./bin/$(BINARY) -version
# Build release for Linux (cross-compile)
build-linux:
@echo "Building $(BINARY) for Linux..."
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o bin/$(BINARY)-linux-amd64 ./cmd/qfs
@echo "✓ Built: bin/$(BINARY)-linux-amd64"
# Build release for macOS (cross-compile)
build-macos:
@echo "Building $(BINARY) for macOS..."
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o bin/$(BINARY)-darwin-amd64 ./cmd/qfs
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o bin/$(BINARY)-darwin-arm64 ./cmd/qfs
@echo "✓ Built: bin/$(BINARY)-darwin-amd64"
@echo "✓ Built: bin/$(BINARY)-darwin-arm64"
# Build release for Windows (cross-compile)
build-windows:
@echo "Building $(BINARY) for Windows..."
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o bin/$(BINARY)-windows-amd64.exe ./cmd/qfs
@echo "✓ Built: bin/$(BINARY)-windows-amd64.exe"
# Build all platforms
build-all: build-release build-linux build-macos build-windows
# Create release packages for all platforms
release:
@./scripts/release.sh
# Show version
version:
@echo "Version: $(VERSION)"
# Clean build artifacts
clean:
rm -rf bin/
rm -f $(BINARY)
# Run tests
test:
go test -v ./...
# Run development server
run:
go run ./cmd/qfs
# Run with auto-restart (requires entr: brew install entr)
watch:
find . -name '*.go' | entr -r go run ./cmd/qfs
# Install dependencies
deps:
go mod download
go mod tidy
# Help
help:
@echo "QuoteForge Server (qfs) - Build Commands"
@echo ""
@echo "Usage: make [target]"
@echo ""
@echo "Targets:"
@echo " build Build for development (with debug info)"
@echo " build-release Build optimized release (default)"
@echo " build-linux Cross-compile for Linux"
@echo " build-macos Cross-compile for macOS (Intel + Apple Silicon)"
@echo " build-windows Cross-compile for Windows"
@echo " build-all Build for all platforms"
@echo " release Create release packages for all platforms"
@echo " version Show current version"
@echo " clean Remove build artifacts"
@echo " test Run tests"
@echo " run Run development server"
@echo " watch Run with auto-restart (requires entr)"
@echo " deps Install/update dependencies"
@echo " help Show this help"
@echo ""
@echo "Current version: $(VERSION)"

170
README.md
View File

@@ -82,9 +82,85 @@ auth:
### 3. Миграции базы данных
```bash
go run ./cmd/server -migrate
go run ./cmd/qfs -migrate
```
### Мигратор OPS -> проекты (preview/apply)
Переносит квоты, чьи названия начинаются с `OPS-xxxx` (где `x` — цифра), в проект `OPS-xxxx`.
Если проекта нет, он будет создан; если архивный — реактивирован.
Сначала всегда смотрите preview:
```bash
go run ./cmd/migrate_ops_projects -config config.yaml
```
Применение изменений:
```bash
go run ./cmd/migrate_ops_projects -config config.yaml -apply
```
Без интерактивного подтверждения:
```bash
go run ./cmd/migrate_ops_projects -config config.yaml -apply -yes
```
### Минимальные права БД для пользователя квотаций
Если нужен пользователь, который может работать с конфигурациями, но не может создавать/удалять прайслисты:
```sql
-- 1) Создать пользователя (если его ещё нет)
CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY 'StrongPassword!';
-- 2) Если пользователь уже существовал, принудительно обновить пароль
ALTER USER 'quote_user'@'%' IDENTIFIED BY 'StrongPassword!';
-- 3) (Опционально, но рекомендуется) удалить дубли пользователя с другими host,
-- чтобы не возникало конфликтов вида user@localhost vs user@'%'
DROP USER IF EXISTS 'quote_user'@'localhost';
DROP USER IF EXISTS 'quote_user'@'127.0.0.1';
DROP USER IF EXISTS 'quote_user'@'::1';
-- 4) Сбросить лишние права
REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'quote_user'@'%';
-- 5) Чтение данных для конфигуратора и синка
GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%';
-- 6) Работа с конфигурациями
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
FLUSH PRIVILEGES;
SHOW GRANTS FOR 'quote_user'@'%';
SHOW CREATE USER 'quote_user'@'%';
```
Полный набор прав для пользователя квотаций:
```sql
GRANT USAGE ON *.* TO 'quote_user'@'%' IDENTIFIED BY 'StrongPassword!';
GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
```
Важно:
- не выдавайте `INSERT/UPDATE/DELETE` на `qt_pricelists` и `qt_pricelist_items`, если пользователь не должен управлять прайслистами;
- если видите ошибку `Access denied for user ...@'<ip>'`, проверьте, что не осталось других записей `quote_user@host` кроме `quote_user@'%'`;
- после смены DB-настроек через `/setup` приложение перезапускается автоматически и подхватывает нового пользователя.
### 4. Импорт метаданных компонентов
```bash
@@ -95,15 +171,77 @@ go run ./cmd/importer
```bash
# Development
go run ./cmd/server
go run ./cmd/qfs
# Production
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge ./cmd/server
./bin/quoteforge
# Production (with Makefile - recommended)
make build-release # Builds with version info
./bin/qfs -version # Check version
# Production (manual)
VERSION=$(git describe --tags --always --dirty)
CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=$VERSION" -o bin/qfs ./cmd/qfs
./bin/qfs -version
```
**Makefile команды:**
```bash
make build-release # Оптимизированная сборка с версией
make build-all # Сборка для всех платформ (Linux, macOS, Windows)
make build-windows # Только для Windows
make run # Запуск dev сервера
make test # Запуск тестов
make clean # Очистка bin/
make help # Показать все команды
```
Приложение будет доступно по адресу: http://localhost:8080
### Локальная SQLite база (state)
Локальная база приложения хранится в профиле пользователя и не зависит от расположения бинарника.
Имя файла: `qfs.db`.
- macOS: `~/Library/Application Support/QuoteForge/qfs.db`
- Linux: `$XDG_STATE_HOME/quoteforge/qfs.db` (или `~/.local/state/quoteforge/qfs.db`)
- Windows: `%LOCALAPPDATA%\\QuoteForge\\qfs.db`
Можно переопределить путь через `-localdb` или переменную окружения `QFS_DB_PATH`.
### Версионность конфигураций (local-first)
Для `local_configurations` используется append-only versioning через полные snapshot-версии:
- таблица: `local_configuration_versions`
- для каждого изменения создаётся новая версия (`version_no = max + 1`)
- `local_configurations.current_version_id` указывает на активную версию
- старые версии не изменяются и не удаляются в обычном потоке
- rollback не "перематывает" историю, а создаёт новую версию из выбранного snapshot
При backfill (миграция `006_add_local_configuration_versions.sql`) для существующих конфигураций создаётся `v1` и проставляется `current_version_id`.
#### Rollback
Rollback выполняется API-методом:
```bash
POST /api/configs/:uuid/rollback
{
"target_version": 3,
"note": "optional"
}
```
Результат:
- создаётся новая версия `vN` с `data` из целевой версии
- `change_note = "rollback to v{target_version}"` (+ note, если передан)
- `current_version_id` переключается на новую версию
- конфигурация уходит в `sync_status = pending`
### Локальный config.yaml
По умолчанию `qfs` ищет `config.yaml` в той же user-state папке, где лежит `qfs.db` (а не рядом с бинарником).
Можно переопределить путь через `-config` или `QFS_CONFIG_PATH`.
## Docker
```bash
@@ -159,8 +297,23 @@ GET /api/components # Список компонентов
POST /api/quote/calculate # Расчёт цены
POST /api/export/xlsx # Экспорт в Excel
GET /api/configs # Сохранённые конфигурации
GET /api/configs/:uuid/versions # Список версий конфигурации
GET /api/configs/:uuid/versions/:version # Получить конкретную версию
POST /api/configs/:uuid/rollback # Rollback на указанную версию
POST /api/configs/:uuid/reactivate # Вернуть архивную конфигурацию в активные
```
#### Sync payload для versioning
События в `pending_changes` для конфигураций содержат:
- `configuration_uuid`
- `operation` (`create` / `update` / `rollback`)
- `current_version_id` и `current_version_no`
- `snapshot` (текущее состояние конфигурации)
- `idempotency_key` и `conflict_policy` (`last_write_wins`)
Это позволяет push-слою отправлять на сервер актуальное состояние и готовит основу для будущего conflict resolution.
## Cron Jobs
QuoteForge now includes automated cron jobs for maintenance tasks. These can be run using the built-in cron functionality in the Docker container.
@@ -209,13 +362,13 @@ go run ./cmd/cron -job=update-popularity
```bash
# Запуск в режиме разработки (hot reload)
go run ./cmd/server
go run ./cmd/qfs
# Запуск тестов
go test ./...
# Сборка для Linux
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge ./cmd/server
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/qfs ./cmd/qfs
```
## Переменные окружения
@@ -229,6 +382,9 @@ CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge ./cmd/server
| `QF_DB_PASSWORD` | Пароль БД | — |
| `QF_JWT_SECRET` | Секрет для JWT | — |
| `QF_SERVER_PORT` | Порт сервера | 8080 |
| `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 |
## Интеграция с существующей БД

View File

@@ -81,4 +81,4 @@ func main() {
log.Println(" - reset-counters: Reset usage counters")
log.Println(" - update-popularity: Update popularity scores")
}
}
}

View File

@@ -6,6 +6,7 @@ import (
"log"
"time"
"git.mchus.pro/mchus/quoteforge/internal/appstate"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
@@ -16,7 +17,11 @@ import (
func main() {
configPath := flag.String("config", "config.yaml", "path to config file")
localDBPath := flag.String("localdb", "./data/settings.db", "path to local SQLite database")
defaultLocalDBPath, err := appstate.ResolveDBPath("")
if err != nil {
log.Fatalf("Failed to resolve default local SQLite path: %v", err)
}
localDBPath := flag.String("localdb", defaultLocalDBPath, "path to local SQLite database (default: user state dir or QFS_DB_PATH)")
dryRun := flag.Bool("dry-run", false, "show what would be migrated without actually doing it")
flag.Parse()
@@ -61,7 +66,7 @@ func main() {
// Get all configurations from MariaDB
var configs []models.Configuration
if err := mariaDB.Preload("User").Find(&configs).Error; err != nil {
if err := mariaDB.Find(&configs).Error; err != nil {
log.Fatalf("Failed to fetch configurations: %v", err)
}
@@ -72,9 +77,9 @@ func main() {
if *dryRun {
log.Println("\n[DRY RUN] Would migrate the following configurations:")
for _, c := range configs {
userName := "unknown"
if c.User != nil {
userName = c.User.Username
userName := c.OwnerUsername
if userName == "" {
userName = "unknown"
}
log.Printf(" - %s (UUID: %s, User: %s, Items: %d)", c.Name, c.UUID, userName, len(c.Items))
}
@@ -110,20 +115,22 @@ func main() {
// Create local configuration
now := time.Now()
localConfig := &localdb.LocalConfiguration{
UUID: c.UUID,
ServerID: &c.ID,
Name: c.Name,
Items: localItems,
TotalPrice: c.TotalPrice,
CustomPrice: c.CustomPrice,
Notes: c.Notes,
IsTemplate: c.IsTemplate,
ServerCount: c.ServerCount,
CreatedAt: c.CreatedAt,
UpdatedAt: now,
SyncedAt: &now,
SyncStatus: "synced",
OriginalUserID: c.UserID,
UUID: c.UUID,
ServerID: &c.ID,
ProjectUUID: c.ProjectUUID,
Name: c.Name,
Items: localItems,
TotalPrice: c.TotalPrice,
CustomPrice: c.CustomPrice,
Notes: c.Notes,
IsTemplate: c.IsTemplate,
ServerCount: c.ServerCount,
CreatedAt: c.CreatedAt,
UpdatedAt: now,
SyncedAt: &now,
SyncStatus: "synced",
OriginalUserID: derefUint(c.UserID),
OriginalUsername: c.OwnerUsername,
}
if err := local.SaveConfiguration(localConfig); err != nil {
@@ -160,3 +167,10 @@ func main() {
fmt.Println("\nDone! You can now run the server with: go run ./cmd/server")
}
func derefUint(v *uint) uint {
if v == nil {
return 0
}
return *v
}

View File

@@ -0,0 +1,283 @@
package main
import (
"bufio"
"flag"
"fmt"
"log"
"os"
"regexp"
"sort"
"strings"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/models"
"github.com/google/uuid"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type configRow struct {
ID uint
UUID string
OwnerUsername string
Name string
ProjectUUID *string
}
type migrationAction struct {
ConfigID uint
ConfigUUID string
ConfigName string
OwnerUsername string
TargetProjectName string
CurrentProject string
NeedCreateProject bool
NeedReactivate bool
}
func main() {
configPath := flag.String("config", "config.yaml", "path to config file")
apply := flag.Bool("apply", false, "apply migration (default is preview only)")
yes := flag.Bool("yes", false, "skip interactive confirmation (works only with -apply)")
flag.Parse()
cfg, err := config.Load(*configPath)
if err != nil {
log.Fatalf("failed to load config: %v", err)
}
db, err := gorm.Open(mysql.Open(cfg.Database.DSN()), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
log.Fatalf("failed to connect database: %v", err)
}
if err := ensureProjectsTable(db); err != nil {
log.Fatalf("precheck failed: %v", err)
}
actions, existingProjects, err := buildPlan(db, cfg.Database.User)
if err != nil {
log.Fatalf("failed to build migration plan: %v", err)
}
printPlan(actions)
if len(actions) == 0 {
fmt.Println("Nothing to migrate.")
return
}
if !*apply {
fmt.Println("\nPreview complete. Re-run with -apply to execute.")
return
}
if !*yes {
ok, confirmErr := askForConfirmation()
if confirmErr != nil {
log.Fatalf("confirmation failed: %v", confirmErr)
}
if !ok {
fmt.Println("Aborted.")
return
}
}
if err := executePlan(db, actions, existingProjects); err != nil {
log.Fatalf("migration failed: %v", err)
}
fmt.Println("Migration completed successfully.")
}
func ensureProjectsTable(db *gorm.DB) error {
var count int64
if err := db.Raw("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'qt_projects'").Scan(&count).Error; err != nil {
return fmt.Errorf("checking qt_projects table: %w", err)
}
if count == 0 {
return fmt.Errorf("table qt_projects does not exist; run migration 009_add_projects.sql first")
}
return nil
}
func buildPlan(db *gorm.DB, fallbackOwner string) ([]migrationAction, map[string]*models.Project, error) {
var configs []configRow
if err := db.Table("qt_configurations").
Select("id, uuid, owner_username, name, project_uuid").
Find(&configs).Error; err != nil {
return nil, nil, fmt.Errorf("load configurations: %w", err)
}
codeRegex := regexp.MustCompile(`^(OPS-[0-9]{4})`)
owners := make(map[string]struct{})
projectNames := make(map[string]struct{})
type candidate struct {
config configRow
code string
owner string
}
candidates := make([]candidate, 0)
for _, cfg := range configs {
match := codeRegex.FindStringSubmatch(strings.TrimSpace(cfg.Name))
if len(match) < 2 {
continue
}
owner := strings.TrimSpace(cfg.OwnerUsername)
if owner == "" {
owner = strings.TrimSpace(fallbackOwner)
}
if owner == "" {
continue
}
code := match[1]
owners[owner] = struct{}{}
projectNames[code] = struct{}{}
candidates = append(candidates, candidate{config: cfg, code: code, owner: owner})
}
ownerList := setKeys(owners)
nameList := setKeys(projectNames)
existingProjects := make(map[string]*models.Project)
if len(ownerList) > 0 && len(nameList) > 0 {
var projects []models.Project
if err := db.Where("owner_username IN ? AND name IN ?", ownerList, nameList).Find(&projects).Error; err != nil {
return nil, nil, fmt.Errorf("load existing projects: %w", err)
}
for i := range projects {
p := projects[i]
existingProjects[projectKey(p.OwnerUsername, p.Name)] = &p
}
}
actions := make([]migrationAction, 0)
for _, c := range candidates {
key := projectKey(c.owner, c.code)
existing := existingProjects[key]
currentProject := ""
if c.config.ProjectUUID != nil {
currentProject = *c.config.ProjectUUID
}
if existing != nil && currentProject == existing.UUID {
continue
}
action := migrationAction{
ConfigID: c.config.ID,
ConfigUUID: c.config.UUID,
ConfigName: c.config.Name,
OwnerUsername: c.owner,
TargetProjectName: c.code,
CurrentProject: currentProject,
}
if existing == nil {
action.NeedCreateProject = true
} else if !existing.IsActive {
action.NeedReactivate = true
}
actions = append(actions, action)
}
return actions, existingProjects, nil
}
func printPlan(actions []migrationAction) {
createCount := 0
reactivateCount := 0
for _, a := range actions {
if a.NeedCreateProject {
createCount++
}
if a.NeedReactivate {
reactivateCount++
}
}
fmt.Printf("Planned actions: %d\n", len(actions))
fmt.Printf("Projects to create: %d\n", createCount)
fmt.Printf("Projects to reactivate: %d\n", reactivateCount)
fmt.Println("\nDetails:")
for _, a := range actions {
extra := ""
if a.NeedCreateProject {
extra = " [create project]"
} else if a.NeedReactivate {
extra = " [reactivate project]"
}
current := a.CurrentProject
if current == "" {
current = "NULL"
}
fmt.Printf("- %s | owner=%s | \"%s\" | project: %s -> %s%s\n",
a.ConfigUUID, a.OwnerUsername, a.ConfigName, current, a.TargetProjectName, extra)
}
}
func askForConfirmation() (bool, error) {
fmt.Print("\nApply these changes? type 'yes' to continue: ")
reader := bufio.NewReader(os.Stdin)
line, err := reader.ReadString('\n')
if err != nil {
return false, err
}
return strings.EqualFold(strings.TrimSpace(line), "yes"), nil
}
func executePlan(db *gorm.DB, actions []migrationAction, existingProjects map[string]*models.Project) error {
return db.Transaction(func(tx *gorm.DB) error {
projectCache := make(map[string]*models.Project, len(existingProjects))
for k, v := range existingProjects {
cp := *v
projectCache[k] = &cp
}
for _, action := range actions {
key := projectKey(action.OwnerUsername, action.TargetProjectName)
project := projectCache[key]
if project == nil {
project = &models.Project{
UUID: uuid.NewString(),
OwnerUsername: action.OwnerUsername,
Name: action.TargetProjectName,
IsActive: true,
IsSystem: false,
}
if err := tx.Create(project).Error; err != nil {
return fmt.Errorf("create project %s for owner %s: %w", action.TargetProjectName, action.OwnerUsername, err)
}
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)
}
project.IsActive = true
}
if err := tx.Table("qt_configurations").Where("id = ?", action.ConfigID).Update("project_uuid", project.UUID).Error; err != nil {
return fmt.Errorf("move configuration %s to project %s: %w", action.ConfigUUID, project.UUID, err)
}
}
return nil
})
}
func setKeys(set map[string]struct{}) []string {
keys := make([]string, 0, len(set))
for k := range set {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
func projectKey(owner, name string) string {
return owner + "||" + name
}

1401
cmd/qfs/main.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,327 @@
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/db"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/services"
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
)
func TestConfigurationVersioningAPI(t *testing.T) {
moveToRepoRoot(t)
local, connMgr, configService := newAPITestStack(t)
_ = local
created, err := configService.Create("tester", &services.CreateConfigRequest{
Name: "api-v1",
Items: models.ConfigItems{{LotName: "CPU_API", Quantity: 1, UnitPrice: 1000}},
ServerCount: 1,
})
if err != nil {
t.Fatalf("create config: %v", err)
}
if _, err := configService.RenameNoAuth(created.UUID, "api-v2"); err != nil {
t.Fatalf("rename config: %v", err)
}
cfg := &config.Config{}
setConfigDefaults(cfg)
router, _, err := setupRouter(cfg, local, connMgr, nil, "tester", nil)
if err != nil {
t.Fatalf("setup router: %v", err)
}
// list versions happy path
listReq := httptest.NewRequest(http.MethodGet, "/api/configs/"+created.UUID+"/versions?limit=10&offset=0", nil)
listRec := httptest.NewRecorder()
router.ServeHTTP(listRec, listReq)
if listRec.Code != http.StatusOK {
t.Fatalf("list versions status=%d body=%s", listRec.Code, listRec.Body.String())
}
// get version happy path
getReq := httptest.NewRequest(http.MethodGet, "/api/configs/"+created.UUID+"/versions/1", nil)
getRec := httptest.NewRecorder()
router.ServeHTTP(getRec, getReq)
if getRec.Code != http.StatusOK {
t.Fatalf("get version status=%d body=%s", getRec.Code, getRec.Body.String())
}
// rollback happy path
body := []byte(`{"target_version":1,"note":"api rollback"}`)
rbReq := httptest.NewRequest(http.MethodPost, "/api/configs/"+created.UUID+"/rollback", bytes.NewReader(body))
rbReq.Header.Set("Content-Type", "application/json")
rbRec := httptest.NewRecorder()
router.ServeHTTP(rbRec, rbReq)
if rbRec.Code != http.StatusOK {
t.Fatalf("rollback status=%d body=%s", rbRec.Code, rbRec.Body.String())
}
var rbResp struct {
Message string `json:"message"`
CurrentVersion struct {
VersionNo int `json:"version_no"`
} `json:"current_version"`
}
if err := json.Unmarshal(rbRec.Body.Bytes(), &rbResp); err != nil {
t.Fatalf("unmarshal rollback response: %v", err)
}
if rbResp.Message == "" || rbResp.CurrentVersion.VersionNo != 3 {
t.Fatalf("unexpected rollback response: %+v", rbResp)
}
// 404: version missing
notFoundReq := httptest.NewRequest(http.MethodGet, "/api/configs/"+created.UUID+"/versions/999", nil)
notFoundRec := httptest.NewRecorder()
router.ServeHTTP(notFoundRec, notFoundReq)
if notFoundRec.Code != http.StatusNotFound {
t.Fatalf("expected 404 for missing version, got %d", notFoundRec.Code)
}
// 400: invalid version number
invalidReq := httptest.NewRequest(http.MethodGet, "/api/configs/"+created.UUID+"/versions/abc", nil)
invalidRec := httptest.NewRecorder()
router.ServeHTTP(invalidRec, invalidReq)
if invalidRec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for invalid version, got %d", invalidRec.Code)
}
// 400: rollback invalid target_version
badRollbackReq := httptest.NewRequest(http.MethodPost, "/api/configs/"+created.UUID+"/rollback", bytes.NewReader([]byte(`{"target_version":0}`)))
badRollbackReq.Header.Set("Content-Type", "application/json")
badRollbackRec := httptest.NewRecorder()
router.ServeHTTP(badRollbackRec, badRollbackReq)
if badRollbackRec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for invalid rollback target, got %d", badRollbackRec.Code)
}
// archive + reactivate flow
delReq := httptest.NewRequest(http.MethodDelete, "/api/configs/"+created.UUID, nil)
delRec := httptest.NewRecorder()
router.ServeHTTP(delRec, delReq)
if delRec.Code != http.StatusOK {
t.Fatalf("archive status=%d body=%s", delRec.Code, delRec.Body.String())
}
archivedListReq := httptest.NewRequest(http.MethodGet, "/api/configs?status=archived&page=1&per_page=20", nil)
archivedListRec := httptest.NewRecorder()
router.ServeHTTP(archivedListRec, archivedListReq)
if archivedListRec.Code != http.StatusOK {
t.Fatalf("archived list status=%d body=%s", archivedListRec.Code, archivedListRec.Body.String())
}
reactivateReq := httptest.NewRequest(http.MethodPost, "/api/configs/"+created.UUID+"/reactivate", nil)
reactivateRec := httptest.NewRecorder()
router.ServeHTTP(reactivateRec, reactivateReq)
if reactivateRec.Code != http.StatusOK {
t.Fatalf("reactivate status=%d body=%s", reactivateRec.Code, reactivateRec.Body.String())
}
activeListReq := httptest.NewRequest(http.MethodGet, "/api/configs?status=active&page=1&per_page=20", nil)
activeListRec := httptest.NewRecorder()
router.ServeHTTP(activeListRec, activeListReq)
if activeListRec.Code != http.StatusOK {
t.Fatalf("active list status=%d body=%s", activeListRec.Code, activeListRec.Body.String())
}
}
func TestProjectArchiveHidesConfigsAndCloneIntoProject(t *testing.T) {
moveToRepoRoot(t)
local, connMgr, configService := newAPITestStack(t)
_ = configService
cfg := &config.Config{}
setConfigDefaults(cfg)
router, _, err := setupRouter(cfg, local, connMgr, nil, "tester", nil)
if err != nil {
t.Fatalf("setup router: %v", err)
}
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"P1"}`)))
createProjectReq.Header.Set("Content-Type", "application/json")
createProjectRec := httptest.NewRecorder()
router.ServeHTTP(createProjectRec, createProjectReq)
if createProjectRec.Code != http.StatusCreated {
t.Fatalf("create project status=%d body=%s", createProjectRec.Code, createProjectRec.Body.String())
}
var project models.Project
if err := json.Unmarshal(createProjectRec.Body.Bytes(), &project); err != nil {
t.Fatalf("unmarshal project: %v", err)
}
createCfgBody := []byte(`{"name":"Cfg A","items":[{"lot_name":"CPU","quantity":1,"unit_price":100}],"server_count":1}`)
createCfgReq := httptest.NewRequest(http.MethodPost, "/api/projects/"+project.UUID+"/configs", bytes.NewReader(createCfgBody))
createCfgReq.Header.Set("Content-Type", "application/json")
createCfgRec := httptest.NewRecorder()
router.ServeHTTP(createCfgRec, createCfgReq)
if createCfgRec.Code != http.StatusCreated {
t.Fatalf("create project config status=%d body=%s", createCfgRec.Code, createCfgRec.Body.String())
}
var createdCfg models.Configuration
if err := json.Unmarshal(createCfgRec.Body.Bytes(), &createdCfg); err != nil {
t.Fatalf("unmarshal project config: %v", err)
}
if createdCfg.ProjectUUID == nil || *createdCfg.ProjectUUID != project.UUID {
t.Fatalf("expected config project_uuid=%s got=%v", project.UUID, createdCfg.ProjectUUID)
}
cloneReq := httptest.NewRequest(http.MethodPost, "/api/projects/"+project.UUID+"/configs/"+createdCfg.UUID+"/clone", bytes.NewReader([]byte(`{"name":"Cfg A Clone"}`)))
cloneReq.Header.Set("Content-Type", "application/json")
cloneRec := httptest.NewRecorder()
router.ServeHTTP(cloneRec, cloneReq)
if cloneRec.Code != http.StatusCreated {
t.Fatalf("clone in project status=%d body=%s", cloneRec.Code, cloneRec.Body.String())
}
var cloneCfg models.Configuration
if err := json.Unmarshal(cloneRec.Body.Bytes(), &cloneCfg); err != nil {
t.Fatalf("unmarshal clone config: %v", err)
}
if cloneCfg.ProjectUUID == nil || *cloneCfg.ProjectUUID != project.UUID {
t.Fatalf("expected clone project_uuid=%s got=%v", project.UUID, cloneCfg.ProjectUUID)
}
projectConfigsReq := httptest.NewRequest(http.MethodGet, "/api/projects/"+project.UUID+"/configs", nil)
projectConfigsRec := httptest.NewRecorder()
router.ServeHTTP(projectConfigsRec, projectConfigsReq)
if projectConfigsRec.Code != http.StatusOK {
t.Fatalf("project configs status=%d body=%s", projectConfigsRec.Code, projectConfigsRec.Body.String())
}
var projectConfigsResp struct {
Configurations []models.Configuration `json:"configurations"`
}
if err := json.Unmarshal(projectConfigsRec.Body.Bytes(), &projectConfigsResp); err != nil {
t.Fatalf("unmarshal project configs response: %v", err)
}
if len(projectConfigsResp.Configurations) != 2 {
t.Fatalf("expected 2 project configs after clone, got %d", len(projectConfigsResp.Configurations))
}
archiveReq := httptest.NewRequest(http.MethodPost, "/api/projects/"+project.UUID+"/archive", nil)
archiveRec := httptest.NewRecorder()
router.ServeHTTP(archiveRec, archiveReq)
if archiveRec.Code != http.StatusOK {
t.Fatalf("archive project status=%d body=%s", archiveRec.Code, archiveRec.Body.String())
}
activeReq := httptest.NewRequest(http.MethodGet, "/api/configs?status=active&page=1&per_page=20", nil)
activeRec := httptest.NewRecorder()
router.ServeHTTP(activeRec, activeReq)
if activeRec.Code != http.StatusOK {
t.Fatalf("active configs status=%d body=%s", activeRec.Code, activeRec.Body.String())
}
var activeResp struct {
Configurations []models.Configuration `json:"configurations"`
}
if err := json.Unmarshal(activeRec.Body.Bytes(), &activeResp); err != nil {
t.Fatalf("unmarshal active configs response: %v", err)
}
if len(activeResp.Configurations) != 0 {
t.Fatalf("expected no active configs after project archive, got %d", len(activeResp.Configurations))
}
}
func TestConfigMoveToProjectEndpoint(t *testing.T) {
moveToRepoRoot(t)
local, connMgr, _ := newAPITestStack(t)
cfg := &config.Config{}
setConfigDefaults(cfg)
router, _, err := setupRouter(cfg, local, connMgr, nil, "tester", nil)
if err != nil {
t.Fatalf("setup router: %v", err)
}
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"Move Project"}`)))
createProjectReq.Header.Set("Content-Type", "application/json")
createProjectRec := httptest.NewRecorder()
router.ServeHTTP(createProjectRec, createProjectReq)
if createProjectRec.Code != http.StatusCreated {
t.Fatalf("create project status=%d body=%s", createProjectRec.Code, createProjectRec.Body.String())
}
var project models.Project
if err := json.Unmarshal(createProjectRec.Body.Bytes(), &project); err != nil {
t.Fatalf("unmarshal project: %v", err)
}
createConfigReq := httptest.NewRequest(http.MethodPost, "/api/configs", bytes.NewReader([]byte(`{"name":"Move Me","items":[],"notes":"","server_count":1}`)))
createConfigReq.Header.Set("Content-Type", "application/json")
createConfigRec := httptest.NewRecorder()
router.ServeHTTP(createConfigRec, createConfigReq)
if createConfigRec.Code != http.StatusCreated {
t.Fatalf("create config status=%d body=%s", createConfigRec.Code, createConfigRec.Body.String())
}
var created models.Configuration
if err := json.Unmarshal(createConfigRec.Body.Bytes(), &created); err != nil {
t.Fatalf("unmarshal config: %v", err)
}
moveReq := httptest.NewRequest(http.MethodPatch, "/api/configs/"+created.UUID+"/project", bytes.NewReader([]byte(`{"project_uuid":"`+project.UUID+`"}`)))
moveReq.Header.Set("Content-Type", "application/json")
moveRec := httptest.NewRecorder()
router.ServeHTTP(moveRec, moveReq)
if moveRec.Code != http.StatusOK {
t.Fatalf("move config status=%d body=%s", moveRec.Code, moveRec.Body.String())
}
getReq := httptest.NewRequest(http.MethodGet, "/api/configs/"+created.UUID, nil)
getRec := httptest.NewRecorder()
router.ServeHTTP(getRec, getReq)
if getRec.Code != http.StatusOK {
t.Fatalf("get config status=%d body=%s", getRec.Code, getRec.Body.String())
}
var updated models.Configuration
if err := json.Unmarshal(getRec.Body.Bytes(), &updated); err != nil {
t.Fatalf("unmarshal updated config: %v", err)
}
if updated.ProjectUUID == nil || *updated.ProjectUUID != project.UUID {
t.Fatalf("expected moved project_uuid=%s, got %v", project.UUID, updated.ProjectUUID)
}
}
func newAPITestStack(t *testing.T) (*localdb.LocalDB, *db.ConnectionManager, *services.LocalConfigurationService) {
t.Helper()
localPath := filepath.Join(t.TempDir(), "api.db")
local, err := localdb.New(localPath)
if err != nil {
t.Fatalf("init local db: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
connMgr := db.NewConnectionManager(local)
syncService := syncsvc.NewService(connMgr, local)
configService := services.NewLocalConfigurationService(
local,
syncService,
&services.QuoteService{},
func() bool { return false },
)
return local, connMgr, configService
}
func moveToRepoRoot(t *testing.T) {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
root := filepath.Clean(filepath.Join(wd, "..", ".."))
if err := os.Chdir(root); err != nil {
t.Fatalf("chdir repo root: %v", err)
}
t.Cleanup(func() {
_ = os.Chdir(wd)
})
}

View File

@@ -1,735 +0,0 @@
package main
import (
"context"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"strconv"
"syscall"
"time"
qfassets "git.mchus.pro/mchus/quoteforge"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/db"
"git.mchus.pro/mchus/quoteforge/internal/handlers"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/middleware"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services"
"git.mchus.pro/mchus/quoteforge/internal/services/alerts"
"git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
"github.com/gin-gonic/gin"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
const (
localDBPath = "./data/settings.db"
)
func main() {
configPath := flag.String("config", "config.yaml", "path to config file (optional, for server settings)")
migrate := flag.Bool("migrate", false, "run database migrations")
flag.Parse()
// Initialize local SQLite database (always used)
local, err := localdb.New(localDBPath)
if err != nil {
slog.Error("failed to initialize local database", "error", err)
os.Exit(1)
}
// Check if running in setup mode (no connection settings)
if !local.HasSettings() {
slog.Info("no database settings found, starting setup mode")
runSetupMode(local)
return
}
// Load config for server settings (optional)
cfg, err := config.Load(*configPath)
if err != nil {
if os.IsNotExist(err) {
// Use defaults if config file doesn't exist
slog.Info("config file not found, using defaults", "path", *configPath)
cfg = &config.Config{}
} else {
slog.Error("failed to load config", "path", *configPath, "error", err)
os.Exit(1)
}
}
setConfigDefaults(cfg)
setupLogger(cfg.Logging)
// Create connection manager and try to connect immediately if settings exist
connMgr := db.NewConnectionManager(local)
dbUser := local.GetDBUser()
dbUserID := uint(1)
// Try to connect to MariaDB on startup
mariaDB, err := connMgr.GetDB()
if err != nil {
slog.Warn("failed to connect to MariaDB on startup, starting in offline mode", "error", err)
mariaDB = nil
} else {
slog.Info("successfully connected to MariaDB on startup")
// Ensure DB user exists and get their ID
if dbUserID, err = models.EnsureDBUser(mariaDB, dbUser); err != nil {
slog.Error("failed to ensure DB user", "error", err)
// Continue with default ID
dbUserID = uint(1)
}
}
slog.Info("starting QuoteForge server",
"host", cfg.Server.Host,
"port", cfg.Server.Port,
"db_user", dbUser,
"db_user_id", dbUserID,
"online", mariaDB != nil,
)
if *migrate {
if mariaDB == nil {
slog.Error("cannot run migrations: database not available")
os.Exit(1)
}
slog.Info("running database migrations...")
if err := models.Migrate(mariaDB); err != nil {
slog.Error("migration failed", "error", err)
os.Exit(1)
}
if err := models.SeedCategories(mariaDB); err != nil {
slog.Error("seeding categories failed", "error", err)
os.Exit(1)
}
slog.Info("migrations completed")
}
gin.SetMode(cfg.Server.Mode)
router, syncService, err := setupRouter(cfg, local, connMgr, mariaDB, dbUserID)
if err != nil {
slog.Error("failed to setup router", "error", err)
os.Exit(1)
}
// Start background sync worker (will auto-skip when offline)
workerCtx, workerCancel := context.WithCancel(context.Background())
defer workerCancel()
syncWorker := sync.NewWorker(syncService, connMgr, 5*time.Minute)
go syncWorker.Start(workerCtx)
srv := &http.Server{
Addr: cfg.Address(),
Handler: router,
ReadTimeout: cfg.Server.ReadTimeout,
WriteTimeout: cfg.Server.WriteTimeout,
}
go func() {
slog.Info("server listening", "address", cfg.Address())
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("server error", "error", err)
os.Exit(1)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
slog.Info("shutting down server...")
// Stop background sync worker first
syncWorker.Stop()
workerCancel()
// Then shutdown HTTP server
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
slog.Error("server forced to shutdown", "error", err)
}
slog.Info("server stopped")
}
func setConfigDefaults(cfg *config.Config) {
if cfg.Server.Host == "" {
cfg.Server.Host = "0.0.0.0"
}
if cfg.Server.Port == 0 {
cfg.Server.Port = 8080
}
if cfg.Server.Mode == "" {
cfg.Server.Mode = "release"
}
if cfg.Server.ReadTimeout == 0 {
cfg.Server.ReadTimeout = 30 * time.Second
}
if cfg.Server.WriteTimeout == 0 {
cfg.Server.WriteTimeout = 30 * time.Second
}
if cfg.Pricing.DefaultMethod == "" {
cfg.Pricing.DefaultMethod = "weighted_median"
}
if cfg.Pricing.DefaultPeriodDays == 0 {
cfg.Pricing.DefaultPeriodDays = 90
}
if cfg.Pricing.FreshnessGreenDays == 0 {
cfg.Pricing.FreshnessGreenDays = 30
}
if cfg.Pricing.FreshnessYellowDays == 0 {
cfg.Pricing.FreshnessYellowDays = 60
}
if cfg.Pricing.FreshnessRedDays == 0 {
cfg.Pricing.FreshnessRedDays = 90
}
if cfg.Pricing.MinQuotesForMedian == 0 {
cfg.Pricing.MinQuotesForMedian = 3
}
}
// runSetupMode starts a minimal server that only serves the setup page
func runSetupMode(local *localdb.LocalDB) {
restartSig := make(chan struct{}, 1)
// In setup mode, we don't have a connection manager yet (will restart after setup)
setupHandler, err := handlers.NewSetupHandler(local, nil, "web/templates", restartSig)
if err != nil {
slog.Error("failed to create setup handler", "error", err)
os.Exit(1)
}
gin.SetMode(gin.ReleaseMode)
router := gin.New()
router.Use(gin.Recovery())
if stat, err := os.Stat("web/static"); err == nil && stat.IsDir() {
router.Static("/static", "web/static")
} else if staticFS, err := qfassets.StaticFS(); err == nil {
router.StaticFS("/static", http.FS(staticFS))
}
// Setup routes only
router.GET("/", func(c *gin.Context) {
c.Redirect(http.StatusFound, "/setup")
})
router.GET("/setup", setupHandler.ShowSetup)
router.POST("/setup", setupHandler.SaveConnection)
router.POST("/setup/test", setupHandler.TestConnection)
router.GET("/setup/status", setupHandler.GetStatus)
// Health check
router.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "setup_required",
"time": time.Now().UTC().Format(time.RFC3339),
})
})
addr := ":8080"
slog.Info("starting setup mode server", "address", addr)
slog.Info("open http://localhost:8080/setup to configure database connection")
srv := &http.Server{
Addr: addr,
Handler: router,
}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("server error", "error", err)
os.Exit(1)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
select {
case <-quit:
slog.Info("setup mode server stopped")
case <-restartSig:
slog.Info("restarting application with saved settings...")
// Graceful shutdown
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
srv.Shutdown(ctx)
// Restart process with same arguments
restartProcess()
}
}
func setupLogger(cfg config.LoggingConfig) {
var level slog.Level
switch cfg.Level {
case "debug":
level = slog.LevelDebug
case "warn":
level = slog.LevelWarn
case "error":
level = slog.LevelError
default:
level = slog.LevelInfo
}
opts := &slog.HandlerOptions{Level: level}
var handler slog.Handler
if cfg.Format == "json" {
handler = slog.NewJSONHandler(os.Stdout, opts)
} else {
handler = slog.NewTextHandler(os.Stdout, opts)
}
slog.SetDefault(slog.New(handler))
}
func setupDatabaseFromDSN(dsn string) (*gorm.DB, error) {
gormLogger := logger.Default.LogMode(logger.Silent)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: gormLogger,
})
if err != nil {
return nil, err
}
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
sqlDB.SetMaxOpenConns(25)
sqlDB.SetMaxIdleConns(5)
sqlDB.SetConnMaxLifetime(5 * time.Minute)
return db, nil
}
func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.ConnectionManager, mariaDB *gorm.DB, dbUserID uint) (*gin.Engine, *sync.Service, error) {
// mariaDB may be nil if we're in offline mode
// Repositories
var componentRepo *repository.ComponentRepository
var categoryRepo *repository.CategoryRepository
var priceRepo *repository.PriceRepository
var alertRepo *repository.AlertRepository
var statsRepo *repository.StatsRepository
var pricelistRepo *repository.PricelistRepository
// Only initialize repositories if we have a database connection
if mariaDB != nil {
componentRepo = repository.NewComponentRepository(mariaDB)
categoryRepo = repository.NewCategoryRepository(mariaDB)
priceRepo = repository.NewPriceRepository(mariaDB)
alertRepo = repository.NewAlertRepository(mariaDB)
statsRepo = repository.NewStatsRepository(mariaDB)
pricelistRepo = repository.NewPricelistRepository(mariaDB)
} else {
// In offline mode, we'll use nil repositories or handle them differently
// This is handled in the sync service and other components
}
// Services
var pricingService *pricing.Service
var componentService *services.ComponentService
var quoteService *services.QuoteService
var exportService *services.ExportService
var alertService *alerts.Service
var pricelistService *pricelist.Service
var syncService *sync.Service
// Sync service always uses ConnectionManager (works offline and online)
syncService = sync.NewService(connMgr, local)
if mariaDB != nil {
pricingService = pricing.NewService(componentRepo, priceRepo, cfg.Pricing)
componentService = services.NewComponentService(componentRepo, categoryRepo, statsRepo)
quoteService = services.NewQuoteService(componentRepo, statsRepo, pricingService)
exportService = services.NewExportService(cfg.Export, categoryRepo)
alertService = alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
pricelistService = pricelist.NewService(mariaDB, pricelistRepo, componentRepo)
} else {
// In offline mode, we still need to create services that don't require DB
pricingService = pricing.NewService(nil, nil, cfg.Pricing)
componentService = services.NewComponentService(nil, nil, nil)
quoteService = services.NewQuoteService(nil, nil, pricingService)
exportService = services.NewExportService(cfg.Export, nil)
alertService = alerts.NewService(nil, nil, nil, nil, cfg.Alerts, cfg.Pricing)
pricelistService = pricelist.NewService(nil, nil, nil)
}
// isOnline function for local-first architecture
isOnline := func() bool {
return connMgr.IsOnline()
}
// Local-first configuration service (replaces old ConfigurationService)
configService := services.NewLocalConfigurationService(local, syncService, quoteService, isOnline)
// Handlers
componentHandler := handlers.NewComponentHandler(componentService, local)
quoteHandler := handlers.NewQuoteHandler(quoteService)
exportHandler := handlers.NewExportHandler(exportService, configService, componentService)
pricingHandler := handlers.NewPricingHandler(mariaDB, pricingService, alertService, componentRepo, priceRepo, statsRepo)
pricelistHandler := handlers.NewPricelistHandler(pricelistService, local)
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, "web/templates")
if err != nil {
return nil, nil, fmt.Errorf("creating sync handler: %w", err)
}
// Setup handler (for reconfiguration) - no restart signal in normal mode
setupHandler, err := handlers.NewSetupHandler(local, connMgr, "web/templates", nil)
if err != nil {
return nil, nil, fmt.Errorf("creating setup handler: %w", err)
}
// Web handler (templates)
webHandler, err := handlers.NewWebHandler("web/templates", componentService)
if err != nil {
return nil, nil, err
}
// Router
router := gin.New()
router.Use(gin.Recovery())
router.Use(requestLogger())
router.Use(middleware.CORS())
router.Use(middleware.OfflineDetector(connMgr, local))
// Static files
if stat, err := os.Stat("web/static"); err == nil && stat.IsDir() {
router.Static("/static", "web/static")
} else if staticFS, err := qfassets.StaticFS(); err == nil {
router.StaticFS("/static", http.FS(staticFS))
}
// Health check
router.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"time": time.Now().UTC().Format(time.RFC3339),
})
})
// DB status endpoint
router.GET("/api/db-status", func(c *gin.Context) {
var lotCount, lotLogCount, metadataCount int64
var dbOK bool = false
var dbError string
// Check if connection exists (fast check, no reconnect attempt)
status := connMgr.GetStatus()
if status.IsConnected {
// Already connected, safe to use
if db, err := connMgr.GetDB(); err == nil && db != nil {
dbOK = true
db.Table("lot").Count(&lotCount)
db.Table("lot_log").Count(&lotLogCount)
db.Table("qt_lot_metadata").Count(&metadataCount)
}
} else {
// Not connected - don't try to reconnect on status check
// This prevents 3s timeout on every request
dbError = "Database not connected (offline mode)"
if status.LastError != "" {
dbError = status.LastError
}
}
c.JSON(http.StatusOK, gin.H{
"connected": dbOK,
"error": dbError,
"lot_count": lotCount,
"lot_log_count": lotLogCount,
"metadata_count": metadataCount,
"db_user": local.GetDBUser(),
})
})
// Current user info (DB user, not app user)
router.GET("/api/current-user", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"username": local.GetDBUser(),
"role": "db_user",
})
})
// Setup routes (for reconfiguration)
router.GET("/setup", setupHandler.ShowSetup)
router.POST("/setup", setupHandler.SaveConnection)
router.POST("/setup/test", setupHandler.TestConnection)
router.GET("/setup/status", setupHandler.GetStatus)
// Web pages
router.GET("/", webHandler.Index)
router.GET("/configs", webHandler.Configs)
router.GET("/configurator", webHandler.Configurator)
router.GET("/pricelists", func(c *gin.Context) {
// Redirect to admin/pricing with pricelists tab
c.Redirect(http.StatusFound, "/admin/pricing?tab=pricelists")
})
router.GET("/pricelists/:id", webHandler.PricelistDetail)
router.GET("/admin/pricing", webHandler.AdminPricing)
// htmx partials
partials := router.Group("/partials")
{
partials.GET("/components", webHandler.ComponentsPartial)
partials.GET("/sync-status", syncHandler.SyncStatusPartial)
}
// API routes
api := router.Group("/api")
{
api.GET("/ping", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "pong"})
})
// Components (public read)
components := api.Group("/components")
{
components.GET("", componentHandler.List)
components.GET("/:lot_name", componentHandler.Get)
}
// Categories (public)
api.GET("/categories", componentHandler.GetCategories)
// Quote (public)
quote := api.Group("/quote")
{
quote.POST("/validate", quoteHandler.Validate)
quote.POST("/calculate", quoteHandler.Calculate)
}
// Export (public)
export := api.Group("/export")
{
export.POST("/csv", exportHandler.ExportCSV)
}
// Pricelists (public - RBAC disabled in Phase 1-3)
pricelists := api.Group("/pricelists")
{
pricelists.GET("", pricelistHandler.List)
pricelists.GET("/can-write", pricelistHandler.CanWrite)
pricelists.GET("/latest", pricelistHandler.GetLatest)
pricelists.GET("/:id", pricelistHandler.Get)
pricelists.GET("/:id/items", pricelistHandler.GetItems)
pricelists.POST("", pricelistHandler.Create)
pricelists.DELETE("/:id", pricelistHandler.Delete)
}
// Configurations (public - RBAC disabled)
configs := api.Group("/configs")
{
configs.GET("", func(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
cfgs, total, err := configService.ListAll(page, perPage)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"configurations": cfgs,
"total": total,
"page": page,
"per_page": perPage,
})
})
configs.POST("", func(c *gin.Context) {
var req services.CreateConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
config, err := configService.Create(dbUserID, &req) // use DB user ID
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, config)
})
configs.GET("/:uuid", func(c *gin.Context) {
uuid := c.Param("uuid")
config, err := configService.GetByUUIDNoAuth(uuid)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
return
}
c.JSON(http.StatusOK, config)
})
configs.PUT("/:uuid", func(c *gin.Context) {
uuid := c.Param("uuid")
var req services.CreateConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
config, err := configService.UpdateNoAuth(uuid, &req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, config)
})
configs.DELETE("/:uuid", func(c *gin.Context) {
uuid := c.Param("uuid")
if err := configService.DeleteNoAuth(uuid); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
})
configs.PATCH("/:uuid/rename", func(c *gin.Context) {
uuid := c.Param("uuid")
var req struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
config, err := configService.RenameNoAuth(uuid, req.Name)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, config)
})
configs.POST("/:uuid/clone", func(c *gin.Context) {
uuid := c.Param("uuid")
var req struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
config, err := configService.CloneNoAuth(uuid, req.Name, dbUserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, config)
})
configs.POST("/:uuid/refresh-prices", func(c *gin.Context) {
uuid := c.Param("uuid")
config, err := configService.RefreshPricesNoAuth(uuid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, config)
})
}
// Pricing admin (public - RBAC disabled)
pricingAdmin := api.Group("/admin/pricing")
{
pricingAdmin.GET("/stats", pricingHandler.GetStats)
pricingAdmin.GET("/components", pricingHandler.ListComponents)
pricingAdmin.GET("/components/:lot_name", pricingHandler.GetComponentPricing)
pricingAdmin.POST("/update", pricingHandler.UpdatePrice)
pricingAdmin.POST("/preview", pricingHandler.PreviewPrice)
pricingAdmin.POST("/recalculate-all", pricingHandler.RecalculateAll)
pricingAdmin.GET("/alerts", pricingHandler.ListAlerts)
pricingAdmin.POST("/alerts/:id/acknowledge", pricingHandler.AcknowledgeAlert)
pricingAdmin.POST("/alerts/:id/resolve", pricingHandler.ResolveAlert)
pricingAdmin.POST("/alerts/:id/ignore", pricingHandler.IgnoreAlert)
}
// Sync API (for offline mode)
syncAPI := api.Group("/sync")
{
syncAPI.GET("/status", syncHandler.GetStatus)
syncAPI.GET("/info", syncHandler.GetInfo)
syncAPI.POST("/components", syncHandler.SyncComponents)
syncAPI.POST("/pricelists", syncHandler.SyncPricelists)
syncAPI.POST("/all", syncHandler.SyncAll)
syncAPI.POST("/push", syncHandler.PushPendingChanges)
syncAPI.GET("/pending/count", syncHandler.GetPendingCount)
syncAPI.GET("/pending", syncHandler.GetPendingChanges)
}
}
return router, syncService, nil
}
// restartProcess restarts the current process with the same arguments
func restartProcess() {
executable, err := os.Executable()
if err != nil {
slog.Error("failed to get executable path", "error", err)
os.Exit(1)
}
args := os.Args
env := os.Environ()
slog.Info("executing restart", "executable", executable, "args", args)
err = syscall.Exec(executable, args, env)
if err != nil {
slog.Error("failed to restart process", "error", err)
os.Exit(1)
}
}
func requestLogger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
query := c.Request.URL.RawQuery
c.Next()
latency := time.Since(start)
status := c.Writer.Status()
slog.Info("request",
"method", c.Request.Method,
"path", path,
"query", query,
"status", status,
"latency", latency,
"ip", c.ClientIP(),
)
}
}

View File

@@ -2,7 +2,7 @@
# Copy this file to config.yaml and update values
server:
host: "0.0.0.0"
host: "127.0.0.1" # Use 0.0.0.0 to listen on all interfaces
port: 8080
mode: "release" # debug | release
read_timeout: "30s"

View File

@@ -0,0 +1,26 @@
package appmeta
import "sync/atomic"
var appVersion atomic.Value
func init() {
appVersion.Store("dev")
}
// SetVersion configures the running application version string.
func SetVersion(v string) {
if v == "" {
v = "dev"
}
appVersion.Store(v)
}
// Version returns the running application version string.
func Version() string {
if v, ok := appVersion.Load().(string); ok && v != "" {
return v
}
return "dev"
}

197
internal/appstate/path.go Normal file
View File

@@ -0,0 +1,197 @@
package appstate
import (
"fmt"
"io"
"os"
"path/filepath"
"runtime"
)
const (
appDirName = "QuoteForge"
defaultDB = "qfs.db"
defaultCfg = "config.yaml"
envDBPath = "QFS_DB_PATH"
envStateDir = "QFS_STATE_DIR"
envCfgPath = "QFS_CONFIG_PATH"
)
// ResolveDBPath returns the local SQLite path using priority:
// explicit CLI path > QFS_DB_PATH > OS-specific user state directory.
func ResolveDBPath(explicitPath string) (string, error) {
if explicitPath != "" {
return filepath.Clean(explicitPath), nil
}
if fromEnv := os.Getenv(envDBPath); fromEnv != "" {
return filepath.Clean(fromEnv), nil
}
dir, err := defaultStateDir()
if err != nil {
return "", err
}
return filepath.Join(dir, defaultDB), nil
}
// ResolveConfigPath returns the config path using priority:
// explicit CLI path > QFS_CONFIG_PATH > OS-specific user state directory.
func ResolveConfigPath(explicitPath string) (string, error) {
if explicitPath != "" {
return filepath.Clean(explicitPath), nil
}
if fromEnv := os.Getenv(envCfgPath); fromEnv != "" {
return filepath.Clean(fromEnv), nil
}
dir, err := defaultStateDir()
if err != nil {
return "", err
}
return filepath.Join(dir, defaultCfg), nil
}
// MigrateLegacyDB copies an existing legacy DB (and optional SQLite sidecars)
// to targetPath if targetPath does not already exist.
// Returns source path if migration happened.
func MigrateLegacyDB(targetPath string, legacyPaths []string) (string, error) {
if targetPath == "" {
return "", nil
}
if exists(targetPath) {
return "", nil
}
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
return "", fmt.Errorf("creating target db directory: %w", err)
}
for _, src := range legacyPaths {
if src == "" {
continue
}
src = filepath.Clean(src)
if src == targetPath || !exists(src) {
continue
}
if err := copyFile(src, targetPath); err != nil {
return "", fmt.Errorf("migrating legacy db from %s: %w", src, err)
}
// Optional SQLite sidecar files.
_ = copyIfExists(src+"-wal", targetPath+"-wal")
_ = copyIfExists(src+"-shm", targetPath+"-shm")
return src, nil
}
return "", nil
}
// MigrateLegacyFile copies an existing legacy file to targetPath
// if targetPath does not already exist.
func MigrateLegacyFile(targetPath string, legacyPaths []string) (string, error) {
if targetPath == "" {
return "", nil
}
if exists(targetPath) {
return "", nil
}
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
return "", fmt.Errorf("creating target directory: %w", err)
}
for _, src := range legacyPaths {
if src == "" {
continue
}
src = filepath.Clean(src)
if src == targetPath || !exists(src) {
continue
}
if err := copyFile(src, targetPath); err != nil {
return "", fmt.Errorf("migrating legacy file from %s: %w", src, err)
}
return src, nil
}
return "", nil
}
func defaultStateDir() (string, error) {
if override := os.Getenv(envStateDir); override != "" {
return filepath.Clean(override), nil
}
switch runtime.GOOS {
case "darwin":
base, err := os.UserConfigDir() // ~/Library/Application Support
if err != nil {
return "", fmt.Errorf("resolving user config dir: %w", err)
}
return filepath.Join(base, appDirName), nil
case "windows":
if local := os.Getenv("LOCALAPPDATA"); local != "" {
return filepath.Join(local, appDirName), nil
}
base, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("resolving user config dir: %w", err)
}
return filepath.Join(base, appDirName), nil
default:
if xdgState := os.Getenv("XDG_STATE_HOME"); xdgState != "" {
return filepath.Join(xdgState, "quoteforge"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolving user home dir: %w", err)
}
return filepath.Join(home, ".local", "state", "quoteforge"), nil
}
}
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func copyIfExists(src, dst string) error {
if !exists(src) {
return nil
}
return copyFile(src, dst)
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
info, err := in.Stat()
if err != nil {
return err
}
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode().Perm())
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return out.Sync()
}

View File

@@ -2,9 +2,12 @@ package config
import (
"fmt"
"net"
"os"
"strconv"
"time"
mysqlDriver "github.com/go-sql-driver/mysql"
"gopkg.in/yaml.v3"
)
@@ -39,8 +42,18 @@ type DatabaseConfig struct {
}
func (d *DatabaseConfig) DSN() string {
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
d.User, d.Password, d.Host, d.Port, d.Name)
cfg := mysqlDriver.NewConfig()
cfg.User = d.User
cfg.Passwd = d.Password
cfg.Net = "tcp"
cfg.Addr = net.JoinHostPort(d.Host, strconv.Itoa(d.Port))
cfg.DBName = d.Name
cfg.ParseTime = true
cfg.Loc = time.Local
cfg.Params = map[string]string{
"charset": "utf8mb4",
}
return cfg.FormatDSN()
}
type AuthConfig struct {
@@ -106,7 +119,7 @@ func Load(path string) (*Config, error) {
func (c *Config) setDefaults() {
if c.Server.Host == "" {
c.Server.Host = "0.0.0.0"
c.Server.Host = "127.0.0.1"
}
if c.Server.Port == 0 {
c.Server.Port = 8080

View File

@@ -32,14 +32,14 @@ type ConnectionStatus struct {
// ConnectionManager manages database connections with thread-safety and connection pooling
type ConnectionManager struct {
localDB *localdb.LocalDB // for getting DSN from settings
mu sync.RWMutex // protects db and state
db *gorm.DB // current connection (nil if not connected)
lastError error // last connection error
lastCheck time.Time // time of last check/attempt
connectTimeout time.Duration // timeout for connection (default: 5s)
pingInterval time.Duration // minimum interval between pings (default: 30s)
reconnectCooldown time.Duration // pause after failed attempt (default: 10s)
localDB *localdb.LocalDB // for getting DSN from settings
mu sync.RWMutex // protects db and state
db *gorm.DB // current connection (nil if not connected)
lastError error // last connection error
lastCheck time.Time // time of last check/attempt
connectTimeout time.Duration // timeout for connection (default: 5s)
pingInterval time.Duration // minimum interval between pings (default: 30s)
reconnectCooldown time.Duration // pause after failed attempt (default: 10s)
}
// NewConnectionManager creates a new ConnectionManager instance
@@ -94,6 +94,8 @@ func (cm *ConnectionManager) GetDB() (*gorm.DB, error) {
// Attempt to connect
err := cm.connect()
if err != nil {
// Drop stale handle so callers don't treat it as an active connection.
cm.db = nil
cm.lastError = err
cm.lastCheck = time.Now()
return nil, err
@@ -147,23 +149,27 @@ func (cm *ConnectionManager) connect() error {
return nil
}
// IsOnline checks if the database is currently connected and responsive
// Does not attempt to reconnect, only checks current state with caching
// IsOnline checks if the database is currently connected and responsive.
// If disconnected, it tries to reconnect (respecting cooldowns in GetDB).
func (cm *ConnectionManager) IsOnline() bool {
cm.mu.RLock()
if cm.db == nil {
cm.mu.RUnlock()
return false
}
// If we've checked recently, return cached result
if time.Since(cm.lastCheck) < cm.pingInterval {
cm.mu.RUnlock()
return true
}
isDisconnected := cm.db == nil
lastErr := cm.lastError
checkedRecently := time.Since(cm.lastCheck) < cm.pingInterval
cm.mu.RUnlock()
// Need to perform actual ping
// Try reconnect in disconnected state.
if isDisconnected {
_, err := cm.GetDB()
return err == nil
}
// If we've checked recently, return cached result.
if checkedRecently {
return lastErr == nil
}
// Need to perform actual ping.
cm.mu.Lock()
defer cm.mu.Unlock()
@@ -282,7 +288,7 @@ func extractHostFromDSN(dsn string) string {
}
if parenEnd != -1 {
// Extract host:port part between tcp( and )
hostPort := dsn[tcpStart+1:parenEnd]
hostPort := dsn[tcpStart+1 : parenEnd]
return hostPort
}
}
@@ -317,7 +323,7 @@ func extractHostFromDSN(dsn string) string {
}
if parenEnd != -1 {
hostPort := dsn[parenStart+1:parenEnd]
hostPort := dsn[parenStart+1 : parenEnd]
return hostPort
}
}
@@ -325,4 +331,4 @@ func extractHostFromDSN(dsn string) string {
// If we can't parse it, return empty string
return ""
}
}

View File

@@ -1,13 +1,12 @@
package handlers
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"git.mchus.pro/mchus/quoteforge/internal/middleware"
"git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/gin-gonic/gin"
)
type ConfigurationHandler struct {
@@ -26,11 +25,11 @@ func NewConfigurationHandler(
}
func (h *ConfigurationHandler) List(c *gin.Context) {
userID := middleware.GetUserID(c)
username := middleware.GetUsername(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
configs, total, err := h.configService.ListByUser(userID, page, perPage)
configs, total, err := h.configService.ListByUser(username, page, perPage)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -45,7 +44,7 @@ func (h *ConfigurationHandler) List(c *gin.Context) {
}
func (h *ConfigurationHandler) Create(c *gin.Context) {
userID := middleware.GetUserID(c)
username := middleware.GetUsername(c)
var req services.CreateConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -53,7 +52,7 @@ func (h *ConfigurationHandler) Create(c *gin.Context) {
return
}
config, err := h.configService.Create(userID, &req)
config, err := h.configService.Create(username, &req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -63,10 +62,10 @@ func (h *ConfigurationHandler) Create(c *gin.Context) {
}
func (h *ConfigurationHandler) Get(c *gin.Context) {
userID := middleware.GetUserID(c)
username := middleware.GetUsername(c)
uuid := c.Param("uuid")
config, err := h.configService.GetByUUID(uuid, userID)
config, err := h.configService.GetByUUID(uuid, username)
if err != nil {
status := http.StatusNotFound
if err == services.ErrConfigForbidden {
@@ -80,7 +79,7 @@ func (h *ConfigurationHandler) Get(c *gin.Context) {
}
func (h *ConfigurationHandler) Update(c *gin.Context) {
userID := middleware.GetUserID(c)
username := middleware.GetUsername(c)
uuid := c.Param("uuid")
var req services.CreateConfigRequest
@@ -89,7 +88,7 @@ func (h *ConfigurationHandler) Update(c *gin.Context) {
return
}
config, err := h.configService.Update(uuid, userID, &req)
config, err := h.configService.Update(uuid, username, &req)
if err != nil {
status := http.StatusInternalServerError
if err == services.ErrConfigNotFound {
@@ -105,10 +104,10 @@ func (h *ConfigurationHandler) Update(c *gin.Context) {
}
func (h *ConfigurationHandler) Delete(c *gin.Context) {
userID := middleware.GetUserID(c)
username := middleware.GetUsername(c)
uuid := c.Param("uuid")
err := h.configService.Delete(uuid, userID)
err := h.configService.Delete(uuid, username)
if err != nil {
status := http.StatusInternalServerError
if err == services.ErrConfigNotFound {
@@ -128,7 +127,7 @@ type RenameConfigRequest struct {
}
func (h *ConfigurationHandler) Rename(c *gin.Context) {
userID := middleware.GetUserID(c)
username := middleware.GetUsername(c)
uuid := c.Param("uuid")
var req RenameConfigRequest
@@ -137,7 +136,7 @@ func (h *ConfigurationHandler) Rename(c *gin.Context) {
return
}
config, err := h.configService.Rename(uuid, userID, req.Name)
config, err := h.configService.Rename(uuid, username, req.Name)
if err != nil {
status := http.StatusInternalServerError
if err == services.ErrConfigNotFound {
@@ -157,7 +156,7 @@ type CloneConfigRequest struct {
}
func (h *ConfigurationHandler) Clone(c *gin.Context) {
userID := middleware.GetUserID(c)
username := middleware.GetUsername(c)
uuid := c.Param("uuid")
var req CloneConfigRequest
@@ -166,7 +165,7 @@ func (h *ConfigurationHandler) Clone(c *gin.Context) {
return
}
config, err := h.configService.Clone(uuid, userID, req.Name)
config, err := h.configService.Clone(uuid, username, req.Name)
if err != nil {
status := http.StatusInternalServerError
if err == services.ErrConfigNotFound {
@@ -182,10 +181,10 @@ func (h *ConfigurationHandler) Clone(c *gin.Context) {
}
func (h *ConfigurationHandler) RefreshPrices(c *gin.Context) {
userID := middleware.GetUserID(c)
username := middleware.GetUsername(c)
uuid := c.Param("uuid")
config, err := h.configService.RefreshPrices(uuid, userID)
config, err := h.configService.RefreshPrices(uuid, username)
if err != nil {
status := http.StatusInternalServerError
if err == services.ErrConfigNotFound {

View File

@@ -5,9 +5,9 @@ import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"git.mchus.pro/mchus/quoteforge/internal/middleware"
"git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/gin-gonic/gin"
)
type ExportHandler struct {
@@ -98,10 +98,10 @@ func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData
}
func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
userID := middleware.GetUserID(c)
username := middleware.GetUsername(c)
uuid := c.Param("uuid")
config, err := h.configService.GetByUUID(uuid, userID)
config, err := h.configService.GetByUUID(uuid, username)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return

View File

@@ -1,12 +1,16 @@
package handlers
import (
"errors"
"fmt"
"io"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
"github.com/gin-gonic/gin"
)
type PricelistHandler struct {
@@ -22,8 +26,20 @@ func NewPricelistHandler(service *pricelist.Service, localDB *localdb.LocalDB) *
func (h *PricelistHandler) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
activeOnly := c.DefaultQuery("active_only", "false") == "true"
source := c.Query("source")
pricelists, total, err := h.service.List(page, perPage)
var (
pricelists any
total int64
err error
)
if activeOnly {
pricelists, total, err = h.service.ListActiveBySource(page, perPage, source)
} else {
pricelists, total, err = h.service.ListBySource(page, perPage, source)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -33,11 +49,21 @@ func (h *PricelistHandler) List(c *gin.Context) {
if total == 0 && h.localDB != nil {
localPLs, err := h.localDB.GetLocalPricelists()
if err == nil && len(localPLs) > 0 {
if source != "" {
filtered := localPLs[:0]
for _, lpl := range localPLs {
if lpl.Source == source {
filtered = append(filtered, lpl)
}
}
localPLs = filtered
}
// Convert to PricelistSummary format
summaries := make([]map[string]interface{}, len(localPLs))
for i, lpl := range localPLs {
summaries[i] = map[string]interface{}{
"id": lpl.ServerID,
"source": lpl.Source,
"version": lpl.Version,
"created_by": "sync",
"item_count": 0, // Not tracked
@@ -87,13 +113,42 @@ func (h *PricelistHandler) Get(c *gin.Context) {
// Create creates a new pricelist from current prices
func (h *PricelistHandler) Create(c *gin.Context) {
canWrite, debugInfo := h.service.CanWriteDebug()
if !canWrite {
c.JSON(http.StatusForbidden, gin.H{
"error": "pricelist write is not allowed",
"debug": debugInfo,
})
return
}
var req struct {
Source string `json:"source"`
Items []struct {
LotName string `json:"lot_name"`
Price float64 `json:"price"`
} `json:"items"`
}
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
source := string(models.NormalizePricelistSource(req.Source))
// Get the database username as the creator
createdBy := h.localDB.GetDBUser()
if createdBy == "" {
createdBy = "unknown"
}
sourceItems := make([]pricelist.CreateItemInput, 0, len(req.Items))
for _, item := range req.Items {
sourceItems = append(sourceItems, pricelist.CreateItemInput{
LotName: item.LotName,
Price: item.Price,
})
}
pl, err := h.service.CreateFromCurrentPrices(createdBy)
pl, err := h.service.CreateForSourceWithProgress(createdBy, source, sourceItems, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -102,8 +157,105 @@ func (h *PricelistHandler) Create(c *gin.Context) {
c.JSON(http.StatusCreated, pl)
}
// CreateWithProgress creates a pricelist and streams progress updates over SSE.
func (h *PricelistHandler) CreateWithProgress(c *gin.Context) {
canWrite, debugInfo := h.service.CanWriteDebug()
if !canWrite {
c.JSON(http.StatusForbidden, gin.H{
"error": "pricelist write is not allowed",
"debug": debugInfo,
})
return
}
var req struct {
Source string `json:"source"`
Items []struct {
LotName string `json:"lot_name"`
Price float64 `json:"price"`
} `json:"items"`
}
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
source := string(models.NormalizePricelistSource(req.Source))
createdBy := h.localDB.GetDBUser()
if createdBy == "" {
createdBy = "unknown"
}
sourceItems := make([]pricelist.CreateItemInput, 0, len(req.Items))
for _, item := range req.Items {
sourceItems = append(sourceItems, pricelist.CreateItemInput{
LotName: item.LotName,
Price: item.Price,
})
}
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
flusher, ok := c.Writer.(http.Flusher)
if !ok {
pl, err := h.service.CreateForSourceWithProgress(createdBy, source, sourceItems, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, pl)
return
}
sendProgress := func(payload gin.H) {
c.SSEvent("progress", payload)
flusher.Flush()
}
sendProgress(gin.H{"current": 0, "total": 4, "status": "starting", "message": "Запуск..."})
pl, err := h.service.CreateForSourceWithProgress(createdBy, source, sourceItems, func(p pricelist.CreateProgress) {
sendProgress(gin.H{
"current": p.Current,
"total": p.Total,
"status": p.Status,
"message": p.Message,
"updated": p.Updated,
"errors": p.Errors,
"lot_name": p.LotName,
})
})
if err != nil {
sendProgress(gin.H{
"current": 0,
"total": 4,
"status": "error",
"message": fmt.Sprintf("Ошибка: %v", err),
})
return
}
sendProgress(gin.H{
"current": 4,
"total": 4,
"status": "completed",
"message": "Готово",
"pricelist": pl,
})
}
// Delete deletes a pricelist by ID
func (h *PricelistHandler) Delete(c *gin.Context) {
canWrite, debugInfo := h.service.CanWriteDebug()
if !canWrite {
c.JSON(http.StatusForbidden, gin.H{
"error": "pricelist write is not allowed",
"debug": debugInfo,
})
return
}
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
@@ -119,6 +271,40 @@ func (h *PricelistHandler) Delete(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "pricelist deleted"})
}
// SetActive toggles active flag on a pricelist.
func (h *PricelistHandler) SetActive(c *gin.Context) {
canWrite, debugInfo := h.service.CanWriteDebug()
if !canWrite {
c.JSON(http.StatusForbidden, gin.H{
"error": "pricelist write is not allowed",
"debug": debugInfo,
})
return
}
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
return
}
var req struct {
IsActive bool `json:"is_active"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.service.SetActive(uint(id), req.IsActive); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "updated", "is_active": req.IsActive})
}
// GetItems returns items for a pricelist with pagination
func (h *PricelistHandler) GetItems(c *gin.Context) {
idStr := c.Param("id")
@@ -137,8 +323,14 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
pl, _ := h.service.GetByID(uint(id))
source := ""
if pl != nil {
source = pl.Source
}
c.JSON(http.StatusOK, gin.H{
"source": source,
"items": items,
"total": total,
"page": page,
@@ -154,15 +346,18 @@ func (h *PricelistHandler) CanWrite(c *gin.Context) {
// GetLatest returns the most recent active pricelist
func (h *PricelistHandler) GetLatest(c *gin.Context) {
source := c.DefaultQuery("source", string(models.PricelistSourceEstimate))
source = string(models.NormalizePricelistSource(source))
// Try to get from server first
pl, err := h.service.GetLatestActive()
pl, err := h.service.GetLatestActiveBySource(source)
if err != nil {
// If offline or no server pricelists, try to get from local cache
if h.localDB == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no database available"})
return
}
localPL, localErr := h.localDB.GetLatestLocalPricelist()
localPL, localErr := h.localDB.GetLatestLocalPricelistBySource(source)
if localErr != nil {
// No local pricelists either
c.JSON(http.StatusNotFound, gin.H{
@@ -174,6 +369,7 @@ func (h *PricelistHandler) GetLatest(c *gin.Context) {
// Return local pricelist
c.JSON(http.StatusOK, gin.H{
"id": localPL.ServerID,
"source": localPL.Source,
"version": localPL.Version,
"created_by": "sync",
"item_count": 0, // Not tracked in local pricelists

View File

@@ -3,8 +3,8 @@ package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/gin-gonic/gin"
)
type QuoteHandler struct {
@@ -49,3 +49,19 @@ func (h *QuoteHandler) Calculate(c *gin.Context) {
"total": result.Total,
})
}
func (h *QuoteHandler) PriceLevels(c *gin.Context) {
var req services.PriceLevelsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
result, err := h.quoteService.CalculatePriceLevels(&req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"html/template"
"log/slog"
"net"
"net/http"
"os"
"path/filepath"
@@ -13,8 +14,9 @@ import (
qfassets "git.mchus.pro/mchus/quoteforge"
"git.mchus.pro/mchus/quoteforge/internal/db"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
mysqlDriver "github.com/go-sql-driver/mysql"
"github.com/gin-gonic/gin"
"gorm.io/driver/mysql"
gormmysql "gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
@@ -93,10 +95,9 @@ func (h *SetupHandler) TestConnection(c *gin.Context) {
}
}
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s",
user, password, host, port, database)
dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
db, err := gorm.Open(gormmysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
@@ -148,6 +149,8 @@ func (h *SetupHandler) TestConnection(c *gin.Context) {
// SaveConnection saves the connection settings and signals restart
func (h *SetupHandler) SaveConnection(c *gin.Context) {
existingSettings, _ := h.localDB.GetSettings()
host := c.PostForm("host")
portStr := c.PostForm("port")
database := c.PostForm("database")
@@ -167,10 +170,9 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) {
}
// Test connection first
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s",
user, password, host, port, database)
dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
db, err := gorm.Open(gormmysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
@@ -202,18 +204,29 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) {
}
}
// Always restart to properly initialize all services with the new connection
settingsChanged := existingSettings == nil ||
existingSettings.Host != host ||
existingSettings.Port != port ||
existingSettings.Database != database ||
existingSettings.User != user ||
existingSettings.PasswordEncrypted != password
restartQueued := settingsChanged && h.restartSig != nil
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Settings saved. Please restart the application to apply changes.",
"restart_required": true,
"message": "Settings saved.",
"restart_required": settingsChanged,
"restart_queued": restartQueued,
})
// Signal restart after response is sent (if restart signal is configured)
if h.restartSig != nil {
if restartQueued {
go func() {
time.Sleep(500 * time.Millisecond) // Give time for response to be sent
h.restartSig <- struct{}{}
select {
case h.restartSig <- struct{}{}:
default:
}
}()
}
}
@@ -241,3 +254,19 @@ func testWritePermission(db *gorm.DB) bool {
return true
}
func buildMySQLDSN(host string, port int, database, user, password string, timeout time.Duration) string {
cfg := mysqlDriver.NewConfig()
cfg.User = user
cfg.Passwd = password
cfg.Net = "tcp"
cfg.Addr = net.JoinHostPort(host, strconv.Itoa(port))
cfg.DBName = database
cfg.ParseTime = true
cfg.Loc = time.Local
cfg.Timeout = timeout
cfg.Params = map[string]string{
"charset": "utf8mb4",
}
return cfg.FormatDSN()
}

View File

@@ -17,14 +17,16 @@ import (
// SyncHandler handles sync API endpoints
type SyncHandler struct {
localDB *localdb.LocalDB
syncService *sync.Service
connMgr *db.ConnectionManager
tmpl *template.Template
localDB *localdb.LocalDB
syncService *sync.Service
connMgr *db.ConnectionManager
autoSyncInterval time.Duration
onlineGraceFactor float64
tmpl *template.Template
}
// NewSyncHandler creates a new sync handler
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, templatesPath string) (*SyncHandler, error) {
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, templatesPath string, autoSyncInterval time.Duration) (*SyncHandler, error) {
// Load sync_status partial template
partialPath := filepath.Join(templatesPath, "partials", "sync_status.html")
var tmpl *template.Template
@@ -39,10 +41,12 @@ func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr
}
return &SyncHandler{
localDB: localDB,
syncService: syncService,
connMgr: connMgr,
tmpl: tmpl,
localDB: localDB,
syncService: syncService,
connMgr: connMgr,
autoSyncInterval: autoSyncInterval,
onlineGraceFactor: 1.10,
tmpl: tmpl,
}, nil
}
@@ -173,18 +177,28 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) {
Synced: synced,
Duration: time.Since(startTime).String(),
})
h.syncService.RecordSyncHeartbeat()
}
// SyncAllResponse represents result of full sync
type SyncAllResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
ComponentsSynced int `json:"components_synced"`
PricelistsSynced int `json:"pricelists_synced"`
Duration string `json:"duration"`
Success bool `json:"success"`
Message string `json:"message"`
PendingPushed int `json:"pending_pushed"`
ComponentsSynced int `json:"components_synced"`
PricelistsSynced int `json:"pricelists_synced"`
ProjectsImported int `json:"projects_imported"`
ProjectsUpdated int `json:"projects_updated"`
ProjectsSkipped int `json:"projects_skipped"`
ConfigurationsImported int `json:"configurations_imported"`
ConfigurationsUpdated int `json:"configurations_updated"`
ConfigurationsSkipped int `json:"configurations_skipped"`
Duration string `json:"duration"`
}
// SyncAll syncs both components and pricelists
// SyncAll performs full bidirectional sync:
// - push pending local changes (projects/configurations) to server
// - pull components, pricelists, projects, and configurations from server
// POST /api/sync/all
func (h *SyncHandler) SyncAll(c *gin.Context) {
if !h.checkOnline() {
@@ -196,7 +210,18 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
}
startTime := time.Now()
var componentsSynced, pricelistsSynced int
var pendingPushed, componentsSynced, pricelistsSynced int
// Push local pending changes first (projects/configurations)
pendingPushed, err := h.syncService.PushPendingChanges()
if err != nil {
slog.Error("pending push failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "Pending changes push failed: " + err.Error(),
})
return
}
// Sync components
mariaDB, err := h.connMgr.GetDB()
@@ -226,18 +251,56 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "Pricelist sync failed: " + err.Error(),
"pending_pushed": pendingPushed,
"components_synced": componentsSynced,
})
return
}
projectsResult, err := h.syncService.ImportProjectsToLocal()
if err != nil {
slog.Error("project import failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "Project import failed: " + err.Error(),
"pending_pushed": pendingPushed,
"components_synced": componentsSynced,
"pricelists_synced": pricelistsSynced,
})
return
}
configsResult, err := h.syncService.ImportConfigurationsToLocal()
if err != nil {
slog.Error("configuration import failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "Configuration import failed: " + err.Error(),
"pending_pushed": pendingPushed,
"components_synced": componentsSynced,
"pricelists_synced": pricelistsSynced,
"projects_imported": projectsResult.Imported,
"projects_updated": projectsResult.Updated,
"projects_skipped": projectsResult.Skipped,
})
return
}
c.JSON(http.StatusOK, SyncAllResponse{
Success: true,
Message: "Full sync completed successfully",
ComponentsSynced: componentsSynced,
PricelistsSynced: pricelistsSynced,
Duration: time.Since(startTime).String(),
Success: true,
Message: "Full sync completed successfully",
PendingPushed: pendingPushed,
ComponentsSynced: componentsSynced,
PricelistsSynced: pricelistsSynced,
ProjectsImported: projectsResult.Imported,
ProjectsUpdated: projectsResult.Updated,
ProjectsSkipped: projectsResult.Skipped,
ConfigurationsImported: configsResult.Imported,
ConfigurationsUpdated: configsResult.Updated,
ConfigurationsSkipped: configsResult.Skipped,
Duration: time.Since(startTime).String(),
})
h.syncService.RecordSyncHeartbeat()
}
// checkOnline checks if MariaDB is accessible
@@ -273,6 +336,7 @@ func (h *SyncHandler) PushPendingChanges(c *gin.Context) {
Synced: pushed,
Duration: time.Since(startTime).String(),
})
h.syncService.RecordSyncHeartbeat()
}
// GetPendingCount returns the number of pending changes
@@ -308,6 +372,14 @@ type SyncInfoResponse struct {
Errors []SyncError `json:"errors,omitempty"`
}
type SyncUsersStatusResponse struct {
IsOnline bool `json:"is_online"`
AutoSyncIntervalSeconds int64 `json:"auto_sync_interval_seconds"`
OnlineThresholdSeconds int64 `json:"online_threshold_seconds"`
GeneratedAt time.Time `json:"generated_at"`
Users []sync.UserSyncStatus `json:"users"`
}
// SyncError represents a sync error
type SyncError struct {
Timestamp time.Time `json:"timestamp"`
@@ -364,6 +436,43 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
})
}
// GetUsersStatus returns last sync timestamps for users with sync heartbeats.
// GET /api/sync/users-status
func (h *SyncHandler) GetUsersStatus(c *gin.Context) {
threshold := time.Duration(float64(h.autoSyncInterval) * h.onlineGraceFactor)
isOnline := h.checkOnline()
if !isOnline {
c.JSON(http.StatusOK, SyncUsersStatusResponse{
IsOnline: false,
AutoSyncIntervalSeconds: int64(h.autoSyncInterval.Seconds()),
OnlineThresholdSeconds: int64(threshold.Seconds()),
GeneratedAt: time.Now().UTC(),
Users: []sync.UserSyncStatus{},
})
return
}
// Keep current client heartbeat fresh so app version is available in the table.
h.syncService.RecordSyncHeartbeat()
users, err := h.syncService.ListUserSyncStatuses(threshold)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}
c.JSON(http.StatusOK, SyncUsersStatusResponse{
IsOnline: true,
AutoSyncIntervalSeconds: int64(h.autoSyncInterval.Seconds()),
OnlineThresholdSeconds: int64(threshold.Seconds()),
GeneratedAt: time.Now().UTC(),
Users: users,
})
}
// SyncStatusPartial renders the sync status partial for htmx
// GET /partials/sync-status
func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {

View File

@@ -67,7 +67,7 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
}
// Load each page template with base
simplePages := []string{"login.html", "configs.html", "admin_pricing.html", "pricelists.html", "pricelist_detail.html"}
simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "admin_pricing.html", "pricelists.html", "pricelist_detail.html"}
for _, page := range simplePages {
pagePath := filepath.Join(templatesPath, page)
var tmpl *template.Template
@@ -186,6 +186,17 @@ func (h *WebHandler) Configs(c *gin.Context) {
h.render(c, "configs.html", gin.H{"ActivePage": "configs"})
}
func (h *WebHandler) Projects(c *gin.Context) {
h.render(c, "projects.html", gin.H{"ActivePage": "projects"})
}
func (h *WebHandler) ProjectDetail(c *gin.Context) {
h.render(c, "project_detail.html", gin.H{
"ActivePage": "projects",
"ProjectUUID": c.Param("uuid"),
})
}
func (h *WebHandler) AdminPricing(c *gin.Context) {
h.render(c, "admin_pricing.html", gin.H{"ActivePage": "admin"})
}

View File

@@ -385,10 +385,9 @@ func (l *LocalDB) EnsureComponentPricesFromPricelists() error {
return nil
}
// If we have components but no prices, we should load prices from pricelists
// Find the latest pricelist
// If we have components but no prices, load from latest estimate pricelist.
var latestPricelist LocalPricelist
if err := l.db.Order("created_at DESC").First(&latestPricelist).Error; err != nil {
if err := l.db.Where("source = ?", "estimate").Order("created_at DESC").First(&latestPricelist).Error; err != nil {
if err == gorm.ErrRecordNotFound {
slog.Warn("no pricelists found in local database")
return nil

View File

@@ -18,19 +18,27 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
}
local := &LocalConfiguration{
UUID: cfg.UUID,
Name: cfg.Name,
Items: items,
TotalPrice: cfg.TotalPrice,
CustomPrice: cfg.CustomPrice,
Notes: cfg.Notes,
IsTemplate: cfg.IsTemplate,
ServerCount: cfg.ServerCount,
PriceUpdatedAt: cfg.PriceUpdatedAt,
CreatedAt: cfg.CreatedAt,
UpdatedAt: time.Now(),
SyncStatus: "pending",
OriginalUserID: cfg.UserID,
UUID: cfg.UUID,
ProjectUUID: cfg.ProjectUUID,
IsActive: true,
Name: cfg.Name,
Items: items,
TotalPrice: cfg.TotalPrice,
CustomPrice: cfg.CustomPrice,
Notes: cfg.Notes,
IsTemplate: cfg.IsTemplate,
ServerCount: cfg.ServerCount,
PricelistID: cfg.PricelistID,
PriceUpdatedAt: cfg.PriceUpdatedAt,
CreatedAt: cfg.CreatedAt,
UpdatedAt: time.Now(),
SyncStatus: "pending",
OriginalUserID: derefUint(cfg.UserID),
OriginalUsername: cfg.OwnerUsername,
}
if local.OriginalUsername == "" && cfg.User != nil {
local.OriginalUsername = cfg.User.Username
}
if cfg.ID > 0 {
@@ -54,7 +62,8 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
cfg := &models.Configuration{
UUID: local.UUID,
UserID: local.OriginalUserID,
OwnerUsername: local.OriginalUsername,
ProjectUUID: local.ProjectUUID,
Name: local.Name,
Items: items,
TotalPrice: local.TotalPrice,
@@ -62,6 +71,7 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
Notes: local.Notes,
IsTemplate: local.IsTemplate,
ServerCount: local.ServerCount,
PricelistID: local.PricelistID,
PriceUpdatedAt: local.PriceUpdatedAt,
CreatedAt: local.CreatedAt,
}
@@ -69,10 +79,57 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
if local.ServerID != nil {
cfg.ID = *local.ServerID
}
if local.OriginalUserID != 0 {
userID := local.OriginalUserID
cfg.UserID = &userID
}
return cfg
}
func derefUint(v *uint) uint {
if v == nil {
return 0
}
return *v
}
func ProjectToLocal(project *models.Project) *LocalProject {
local := &LocalProject{
UUID: project.UUID,
OwnerUsername: project.OwnerUsername,
Name: project.Name,
TrackerURL: project.TrackerURL,
IsActive: project.IsActive,
IsSystem: project.IsSystem,
CreatedAt: project.CreatedAt,
UpdatedAt: project.UpdatedAt,
SyncStatus: "pending",
}
if project.ID > 0 {
serverID := project.ID
local.ServerID = &serverID
}
return local
}
func LocalToProject(local *LocalProject) *models.Project {
project := &models.Project{
UUID: local.UUID,
OwnerUsername: local.OwnerUsername,
Name: local.Name,
TrackerURL: local.TrackerURL,
IsActive: local.IsActive,
IsSystem: local.IsSystem,
CreatedAt: local.CreatedAt,
UpdatedAt: local.UpdatedAt,
}
if local.ServerID != nil {
project.ID = *local.ServerID
}
return project
}
// PricelistToLocal converts models.Pricelist to LocalPricelist
func PricelistToLocal(pl *models.Pricelist) *LocalPricelist {
name := pl.Notification
@@ -82,6 +139,7 @@ func PricelistToLocal(pl *models.Pricelist) *LocalPricelist {
return &LocalPricelist{
ServerID: pl.ID,
Source: pl.Source,
Version: pl.Version,
Name: name,
CreatedAt: pl.CreatedAt,
@@ -94,6 +152,7 @@ func PricelistToLocal(pl *models.Pricelist) *LocalPricelist {
func LocalToPricelist(local *LocalPricelist) *models.Pricelist {
return &models.Pricelist{
ID: local.ServerID,
Source: local.Source,
Version: local.Version,
Notification: local.Name,
CreatedAt: local.CreatedAt,

View File

@@ -0,0 +1,127 @@
package localdb
import (
"path/filepath"
"testing"
"time"
)
func TestRunLocalMigrationsBackfillsExistingConfigurations(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "legacy_local.db")
local, err := New(dbPath)
if err != nil {
t.Fatalf("open localdb: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
cfg := &LocalConfiguration{
UUID: "legacy-cfg",
Name: "Legacy",
Items: LocalConfigItems{},
SyncStatus: "pending",
OriginalUsername: "tester",
IsActive: true,
}
if err := local.SaveConfiguration(cfg); err != nil {
t.Fatalf("save seed config: %v", err)
}
if err := local.DB().Where("configuration_uuid = ?", "legacy-cfg").Delete(&LocalConfigurationVersion{}).Error; err != nil {
t.Fatalf("delete seed versions: %v", err)
}
if err := local.DB().Model(&LocalConfiguration{}).
Where("uuid = ?", "legacy-cfg").
Update("current_version_id", nil).Error; err != nil {
t.Fatalf("clear current_version_id: %v", err)
}
if err := local.DB().Where("1=1").Delete(&LocalSchemaMigration{}).Error; err != nil {
t.Fatalf("clear migration records: %v", err)
}
if err := runLocalMigrations(local.DB()); err != nil {
t.Fatalf("run local migrations manually: %v", err)
}
migratedCfg, err := local.GetConfigurationByUUID("legacy-cfg")
if err != nil {
t.Fatalf("get migrated config: %v", err)
}
if migratedCfg.CurrentVersionID == nil || *migratedCfg.CurrentVersionID == "" {
t.Fatalf("expected current_version_id after migration")
}
if !migratedCfg.IsActive {
t.Fatalf("expected migrated config to be active")
}
var versionCount int64
if err := local.DB().Model(&LocalConfigurationVersion{}).
Where("configuration_uuid = ?", "legacy-cfg").
Count(&versionCount).Error; err != nil {
t.Fatalf("count versions: %v", err)
}
if versionCount != 1 {
t.Fatalf("expected 1 backfilled version, got %d", versionCount)
}
var migrationCount int64
if err := local.DB().Model(&LocalSchemaMigration{}).Count(&migrationCount).Error; err != nil {
t.Fatalf("count local migrations: %v", err)
}
if migrationCount == 0 {
t.Fatalf("expected local migrations to be recorded")
}
}
func TestRunLocalMigrationsFixesPricelistVersionUniqueIndex(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "pricelist_index_fix.db")
local, err := New(dbPath)
if err != nil {
t.Fatalf("open localdb: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
if err := local.SaveLocalPricelist(&LocalPricelist{
ServerID: 10,
Version: "2026-02-06-001",
Name: "v1",
CreatedAt: time.Now().Add(-time.Hour),
SyncedAt: time.Now().Add(-time.Hour),
}); err != nil {
t.Fatalf("save first pricelist: %v", err)
}
if err := local.DB().Exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_local_pricelists_version_legacy
ON local_pricelists(version)
`).Error; err != nil {
t.Fatalf("create legacy unique version index: %v", err)
}
if err := local.DB().Where("id = ?", "2026_02_06_pricelist_index_fix").
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)
}
if err := local.SaveLocalPricelist(&LocalPricelist{
ServerID: 11,
Version: "2026-02-06-001",
Name: "v1-duplicate-version",
CreatedAt: time.Now(),
SyncedAt: time.Now(),
}); err != nil {
t.Fatalf("save second pricelist with duplicate version: %v", err)
}
var count int64
if err := local.DB().Model(&LocalPricelist{}).Count(&count).Error; err != nil {
t.Fatalf("count pricelists: %v", err)
}
if count != 2 {
t.Fatalf("expected 2 pricelists, got %d", count)
}
}

View File

@@ -1,14 +1,22 @@
package localdb
import (
"errors"
"fmt"
"log/slog"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
"github.com/glebarez/sqlite"
mysqlDriver "github.com/go-sql-driver/mysql"
uuidpkg "github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/logger"
)
@@ -51,7 +59,9 @@ func New(dbPath string) (*LocalDB, error) {
// Auto-migrate all local tables
if err := db.AutoMigrate(
&ConnectionSettings{},
&LocalProject{},
&LocalConfiguration{},
&LocalConfigurationVersion{},
&LocalPricelist{},
&LocalPricelistItem{},
&LocalComponent{},
@@ -60,6 +70,9 @@ func New(dbPath string) (*LocalDB, error) {
); err != nil {
return nil, fmt.Errorf("migrating sqlite database: %w", err)
}
if err := runLocalMigrations(db); err != nil {
return nil, fmt.Errorf("running local sqlite migrations: %w", err)
}
slog.Info("local SQLite database initialized", "path", dbPath)
@@ -132,19 +145,23 @@ func (l *LocalDB) GetDSN() (string, error) {
return "", err
}
// Add aggressive timeouts for offline-first architecture
// timeout: connection establishment timeout (3s)
// readTimeout: I/O read timeout (3s)
// writeTimeout: I/O write timeout (3s)
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=3s&readTimeout=3s&writeTimeout=3s",
settings.User,
settings.PasswordEncrypted, // Contains decrypted password after GetSettings
settings.Host,
settings.Port,
settings.Database,
)
cfg := mysqlDriver.NewConfig()
cfg.User = settings.User
cfg.Passwd = settings.PasswordEncrypted // Contains decrypted password after GetSettings
cfg.Net = "tcp"
cfg.Addr = net.JoinHostPort(settings.Host, strconv.Itoa(settings.Port))
cfg.DBName = settings.Database
cfg.ParseTime = true
cfg.Loc = time.Local
// Add aggressive timeouts for offline-first architecture.
cfg.Timeout = 3 * time.Second
cfg.ReadTimeout = 3 * time.Second
cfg.WriteTimeout = 3 * time.Second
cfg.Params = map[string]string{
"charset": "utf8mb4",
}
return dsn, nil
return cfg.FormatDSN(), nil
}
// DB returns the underlying gorm.DB for advanced operations
@@ -172,6 +189,216 @@ func (l *LocalDB) GetDBUser() string {
// Configuration methods
// Project methods
func (l *LocalDB) SaveProject(project *LocalProject) error {
return l.db.Save(project).Error
}
func (l *LocalDB) GetProjects(ownerUsername string, includeArchived bool) ([]LocalProject, error) {
var projects []LocalProject
query := l.db.Model(&LocalProject{}).Where("owner_username = ?", ownerUsername)
if !includeArchived {
query = query.Where("is_active = ?", true)
}
err := query.Order("created_at DESC").Find(&projects).Error
return projects, err
}
func (l *LocalDB) GetAllProjects(includeArchived bool) ([]LocalProject, error) {
var projects []LocalProject
query := l.db.Model(&LocalProject{})
if !includeArchived {
query = query.Where("is_active = ?", true)
}
err := query.Order("created_at DESC").Find(&projects).Error
return projects, err
}
func (l *LocalDB) GetProjectByUUID(uuid string) (*LocalProject, error) {
var project LocalProject
if err := l.db.Where("uuid = ?", uuid).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 {
return nil, err
}
return &project, nil
}
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").
Find(&configs).Error
return configs, err
}
func (l *LocalDB) EnsureDefaultProject(ownerUsername string) (*LocalProject, error) {
project := &LocalProject{}
err := l.db.
Where("LOWER(TRIM(COALESCE(name, ''))) = LOWER(?) AND is_system = ?", "Без проекта", true).
Order("CASE WHEN TRIM(COALESCE(owner_username, '')) = '' THEN 0 ELSE 1 END, created_at ASC, id ASC").
First(project).Error
if err == nil {
return project, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
now := time.Now()
project = &LocalProject{
UUID: uuidpkg.NewString(),
OwnerUsername: "",
Name: "Без проекта",
IsActive: true,
IsSystem: true,
CreatedAt: now,
UpdatedAt: now,
SyncStatus: "pending",
}
if err := l.SaveProject(project); err != nil {
return nil, err
}
return project, nil
}
// ConsolidateSystemProjects merges all "Без проекта" projects into one shared canonical project.
// Configurations are reassigned to canonical UUID, duplicate projects are deleted.
func (l *LocalDB) ConsolidateSystemProjects() (int64, error) {
var removed int64
err := l.db.Transaction(func(tx *gorm.DB) error {
var canonical LocalProject
err := tx.
Where("LOWER(TRIM(COALESCE(name, ''))) = LOWER(?) AND is_system = ?", "Без проекта", true).
Order("CASE WHEN TRIM(COALESCE(owner_username, '')) = '' THEN 0 ELSE 1 END, created_at ASC, id ASC").
First(&canonical).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
now := time.Now()
canonical = LocalProject{
UUID: uuidpkg.NewString(),
OwnerUsername: "",
Name: "Без проекта",
IsActive: true,
IsSystem: true,
CreatedAt: now,
UpdatedAt: now,
SyncStatus: "pending",
}
if err := tx.Create(&canonical).Error; err != nil {
return err
}
} else if err != nil {
return err
}
if err := tx.Model(&LocalProject{}).
Where("uuid = ?", canonical.UUID).
Updates(map[string]any{
"name": "Без проекта",
"is_system": true,
"is_active": true,
}).Error; err != nil {
return err
}
var duplicates []LocalProject
if err := tx.Where("LOWER(TRIM(COALESCE(name, ''))) = LOWER(?) AND uuid <> ?", "Без проекта", canonical.UUID).
Find(&duplicates).Error; err != nil {
return err
}
for i := range duplicates {
p := duplicates[i]
if err := tx.Model(&LocalConfiguration{}).
Where("project_uuid = ?", p.UUID).
Update("project_uuid", canonical.UUID).Error; err != nil {
return err
}
// Remove stale pending project events for deleted UUIDs.
if err := tx.Where("entity_type = ? AND entity_uuid = ?", "project", p.UUID).
Delete(&PendingChange{}).Error; err != nil {
return err
}
res := tx.Where("uuid = ?", p.UUID).Delete(&LocalProject{})
if res.Error != nil {
return res.Error
}
removed += res.RowsAffected
}
// Backfill orphaned local configurations to canonical project.
if err := tx.Model(&LocalConfiguration{}).
Where("project_uuid IS NULL OR TRIM(COALESCE(project_uuid, '')) = ''").
Update("project_uuid", canonical.UUID).Error; err != nil {
return err
}
return nil
})
return removed, err
}
// PurgeEmptyNamelessProjects removes service-trash projects that have no linked configurations:
// 1) projects with empty names;
// 2) duplicate "Без проекта" rows without configurations (case-insensitive, trimmed).
func (l *LocalDB) PurgeEmptyNamelessProjects() (int64, error) {
tx := l.db.Exec(`
DELETE FROM local_projects
WHERE (
TRIM(COALESCE(name, '')) = ''
OR LOWER(TRIM(COALESCE(name, ''))) = LOWER('Без проекта')
)
AND uuid NOT IN (
SELECT DISTINCT project_uuid
FROM local_configurations
WHERE project_uuid IS NOT NULL AND project_uuid <> ''
)`)
return tx.RowsAffected, tx.Error
}
// BackfillConfigurationProjects ensures every configuration has project_uuid set.
// If missing, it assigns system project "Без проекта" for configuration owner.
func (l *LocalDB) BackfillConfigurationProjects(defaultOwner string) error {
configs, err := l.GetConfigurations()
if err != nil {
return err
}
for i := range configs {
cfg := configs[i]
if cfg.ProjectUUID != nil && *cfg.ProjectUUID != "" {
continue
}
owner := strings.TrimSpace(cfg.OriginalUsername)
if owner == "" {
owner = strings.TrimSpace(defaultOwner)
}
if owner == "" {
continue
}
project, err := l.EnsureDefaultProject(owner)
if err != nil {
return err
}
cfg.ProjectUUID = &project.UUID
if saveErr := l.SaveConfiguration(&cfg); saveErr != nil {
return saveErr
}
}
return nil
}
// SaveConfiguration saves a configuration to local SQLite
func (l *LocalDB) SaveConfiguration(config *LocalConfiguration) error {
return l.db.Save(config).Error
@@ -193,7 +420,60 @@ func (l *LocalDB) GetConfigurationByUUID(uuid string) (*LocalConfiguration, erro
// DeleteConfiguration deletes a configuration by UUID
func (l *LocalDB) DeleteConfiguration(uuid string) error {
return l.db.Where("uuid = ?", uuid).Delete(&LocalConfiguration{}).Error
return l.DeactivateConfiguration(uuid)
}
// DeactivateConfiguration marks configuration as inactive and appends one snapshot version.
func (l *LocalDB) DeactivateConfiguration(uuid string) error {
return l.db.Transaction(func(tx *gorm.DB) error {
var cfg LocalConfiguration
if err := tx.Where("uuid = ?", uuid).First(&cfg).Error; err != nil {
return err
}
if !cfg.IsActive {
return nil
}
cfg.IsActive = false
cfg.UpdatedAt = time.Now()
cfg.SyncStatus = "pending"
if err := tx.Save(&cfg).Error; err != nil {
return fmt.Errorf("save inactive configuration: %w", err)
}
var maxVersion int
if err := tx.Model(&LocalConfigurationVersion{}).
Where("configuration_uuid = ?", cfg.UUID).
Select("COALESCE(MAX(version_no), 0)").
Scan(&maxVersion).Error; err != nil {
return fmt.Errorf("read max version for deactivate: %w", err)
}
snapshot, err := BuildConfigurationSnapshot(&cfg)
if err != nil {
return fmt.Errorf("build deactivate snapshot: %w", err)
}
note := "deactivate via local delete"
version := &LocalConfigurationVersion{
ID: uuidpkg.NewString(),
ConfigurationUUID: cfg.UUID,
VersionNo: maxVersion + 1,
Data: snapshot,
ChangeNote: &note,
AppVersion: appmeta.Version(),
CreatedAt: time.Now(),
}
if err := tx.Create(version).Error; err != nil {
return fmt.Errorf("insert deactivate version: %w", err)
}
if err := tx.Model(&LocalConfiguration{}).
Where("uuid = ?", cfg.UUID).
Update("current_version_id", version.ID).Error; err != nil {
return fmt.Errorf("set current version after deactivate: %w", err)
}
return nil
})
}
// CountConfigurations returns the number of local configurations
@@ -243,7 +523,16 @@ func (l *LocalDB) CountLocalPricelists() int64 {
// GetLatestLocalPricelist returns the most recently synced pricelist
func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
var pricelist LocalPricelist
if err := l.db.Order("created_at DESC").First(&pricelist).Error; err != nil {
if err := l.db.Where("source = ?", "estimate").Order("created_at DESC").First(&pricelist).Error; err != nil {
return nil, err
}
return &pricelist, nil
}
// GetLatestLocalPricelistBySource returns the most recently synced pricelist for a source.
func (l *LocalDB) GetLatestLocalPricelistBySource(source string) (*LocalPricelist, error) {
var pricelist LocalPricelist
if err := l.db.Where("source = ?", source).Order("created_at DESC").First(&pricelist).Error; err != nil {
return nil, err
}
return &pricelist, nil
@@ -258,6 +547,24 @@ func (l *LocalDB) GetLocalPricelistByServerID(serverID uint) (*LocalPricelist, e
return &pricelist, nil
}
// GetLocalPricelistByVersion returns a local pricelist by version string.
func (l *LocalDB) GetLocalPricelistByVersion(version string) (*LocalPricelist, error) {
var pricelist LocalPricelist
if err := l.db.Where("version = ? AND source = ?", version, "estimate").First(&pricelist).Error; err != nil {
return nil, err
}
return &pricelist, nil
}
// GetLocalPricelistBySourceAndVersion returns a local pricelist by source and version string.
func (l *LocalDB) GetLocalPricelistBySourceAndVersion(source, version string) (*LocalPricelist, error) {
var pricelist LocalPricelist
if err := l.db.Where("source = ? AND version = ?", source, version).First(&pricelist).Error; err != nil {
return nil, err
}
return &pricelist, nil
}
// GetLocalPricelistByID returns a local pricelist by its local ID
func (l *LocalDB) GetLocalPricelistByID(id uint) (*LocalPricelist, error) {
var pricelist LocalPricelist
@@ -269,7 +576,17 @@ func (l *LocalDB) GetLocalPricelistByID(id uint) (*LocalPricelist, error) {
// SaveLocalPricelist saves a pricelist to local SQLite
func (l *LocalDB) SaveLocalPricelist(pricelist *LocalPricelist) error {
return l.db.Save(pricelist).Error
return l.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "server_id"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"source": pricelist.Source,
"version": pricelist.Version,
"name": pricelist.Name,
"created_at": pricelist.CreatedAt,
"synced_at": pricelist.SyncedAt,
"is_used": pricelist.IsUsed,
}),
}).Create(pricelist).Error
}
// GetLocalPricelists returns all local pricelists
@@ -333,6 +650,25 @@ func (l *LocalDB) MarkPricelistAsUsed(pricelistID uint, isUsed bool) error {
Update("is_used", isUsed).Error
}
// RecalculateAllLocalPricelistUsage refreshes local_pricelists.is_used based on active configurations.
func (l *LocalDB) RecalculateAllLocalPricelistUsage() error {
return l.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&LocalPricelist{}).Where("1 = 1").Update("is_used", false).Error; err != nil {
return err
}
return tx.Exec(`
UPDATE local_pricelists
SET is_used = 1
WHERE server_id IN (
SELECT DISTINCT pricelist_id
FROM local_configurations
WHERE pricelist_id IS NOT NULL AND is_active = 1
)
`).Error
})
}
// DeleteLocalPricelist deletes a pricelist and its items
func (l *LocalDB) DeleteLocalPricelist(id uint) error {
// Delete items first
@@ -415,6 +751,16 @@ func (l *LocalDB) MarkChangesSynced(ids []int64) error {
return l.db.Where("id IN ?", ids).Delete(&PendingChange{}).Error
}
// PurgeOrphanConfigurationPendingChanges removes configuration pending changes
// whose entity_uuid no longer exists in local_configurations.
func (l *LocalDB) PurgeOrphanConfigurationPendingChanges() (int64, error) {
tx := l.db.Where(
"entity_type = ? AND entity_uuid NOT IN (SELECT uuid FROM local_configurations)",
"configuration",
).Delete(&PendingChange{})
return tx.RowsAffected, tx.Error
}
// GetPendingCount returns the total number of pending changes (alias for CountPendingChanges)
func (l *LocalDB) GetPendingCount() int64 {
return l.CountPendingChanges()

View File

@@ -0,0 +1,60 @@
package localdb
import (
"path/filepath"
"testing"
)
func TestRunLocalMigrationsBackfillsDefaultProject(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "projects_backfill.db")
local, err := New(dbPath)
if err != nil {
t.Fatalf("open localdb: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
cfg := &LocalConfiguration{
UUID: "cfg-without-project",
Name: "Cfg no project",
Items: LocalConfigItems{},
SyncStatus: "pending",
OriginalUsername: "tester",
IsActive: true,
}
if err := local.SaveConfiguration(cfg); err != nil {
t.Fatalf("save config: %v", err)
}
if err := local.DB().
Model(&LocalConfiguration{}).
Where("uuid = ?", cfg.UUID).
Update("project_uuid", nil).Error; err != nil {
t.Fatalf("clear project_uuid: %v", err)
}
if err := local.DB().Where("id = ?", "2026_02_06_projects_backfill").Delete(&LocalSchemaMigration{}).Error; err != nil {
t.Fatalf("delete local migration record: %v", err)
}
if err := runLocalMigrations(local.DB()); err != nil {
t.Fatalf("run local migrations: %v", err)
}
updated, err := local.GetConfigurationByUUID(cfg.UUID)
if err != nil {
t.Fatalf("get updated config: %v", err)
}
if updated.ProjectUUID == nil || *updated.ProjectUUID == "" {
t.Fatalf("expected project_uuid to be backfilled")
}
project, err := local.GetProjectByUUID(*updated.ProjectUUID)
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.IsSystem {
t.Fatalf("expected system project flag")
}
}

View File

@@ -0,0 +1,131 @@
package localdb
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestMigration006BackfillCreatesV1AndCurrentPointer(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "migration_backfill.db")
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.Exec(`
CREATE TABLE local_configurations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
server_id INTEGER NULL,
name TEXT NOT NULL,
items TEXT,
total_price REAL,
custom_price REAL,
notes TEXT,
is_template BOOLEAN DEFAULT FALSE,
server_count INTEGER DEFAULT 1,
price_updated_at DATETIME NULL,
created_at DATETIME,
updated_at DATETIME,
synced_at DATETIME,
sync_status TEXT DEFAULT 'local',
original_user_id INTEGER DEFAULT 0,
original_username TEXT DEFAULT ''
);`).Error; err != nil {
t.Fatalf("create pre-migration schema: %v", err)
}
items := `[{"lot_name":"CPU_X","quantity":2,"unit_price":1000}]`
now := time.Now().UTC().Format(time.RFC3339)
if err := db.Exec(`
INSERT INTO local_configurations
(uuid, name, items, total_price, notes, server_count, created_at, updated_at, sync_status, original_username)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
"cfg-1", "Cfg One", items, 2000.0, "note", 1, now, now, "pending", "tester",
).Error; err != nil {
t.Fatalf("seed pre-migration data: %v", err)
}
migrationPath := filepath.Join("..", "..", "migrations", "006_add_local_configuration_versions.sql")
sqlBytes, err := os.ReadFile(migrationPath)
if err != nil {
t.Fatalf("read migration file: %v", err)
}
if err := execSQLScript(db, string(sqlBytes)); err != nil {
t.Fatalf("apply migration: %v", err)
}
var count int64
if err := db.Table("local_configuration_versions").Where("configuration_uuid = ?", "cfg-1").Count(&count).Error; err != nil {
t.Fatalf("count versions: %v", err)
}
if count != 1 {
t.Fatalf("expected 1 version, got %d", count)
}
var currentVersionID *string
if err := db.Table("local_configurations").Select("current_version_id").Where("uuid = ?", "cfg-1").Scan(&currentVersionID).Error; err != nil {
t.Fatalf("read current_version_id: %v", err)
}
if currentVersionID == nil || *currentVersionID == "" {
t.Fatalf("expected current_version_id to be set")
}
var row struct {
ID string
VersionNo int
Data string
}
if err := db.Table("local_configuration_versions").
Select("id, version_no, data").
Where("configuration_uuid = ?", "cfg-1").
First(&row).Error; err != nil {
t.Fatalf("load v1 row: %v", err)
}
if row.VersionNo != 1 {
t.Fatalf("expected version_no=1, got %d", row.VersionNo)
}
if row.ID != *currentVersionID {
t.Fatalf("expected current_version_id=%s, got %s", row.ID, *currentVersionID)
}
var snapshot map[string]any
if err := json.Unmarshal([]byte(row.Data), &snapshot); err != nil {
t.Fatalf("parse snapshot json: %v", err)
}
if snapshot["uuid"] != "cfg-1" {
t.Fatalf("expected snapshot uuid cfg-1, got %v", snapshot["uuid"])
}
if snapshot["name"] != "Cfg One" {
t.Fatalf("expected snapshot name Cfg One, got %v", snapshot["name"])
}
}
func execSQLScript(db *gorm.DB, script string) error {
var cleaned []string
for _, line := range strings.Split(script, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "--") {
continue
}
cleaned = append(cleaned, line)
}
for _, stmt := range strings.Split(strings.Join(cleaned, "\n"), ";") {
sql := strings.TrimSpace(stmt)
if sql == "" {
continue
}
if err := db.Exec(sql).Error; err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,318 @@
package localdb
import (
"errors"
"fmt"
"log/slog"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type LocalSchemaMigration struct {
ID string `gorm:"primaryKey;size:128"`
Name string `gorm:"not null;size:255"`
AppliedAt time.Time `gorm:"not null"`
}
func (LocalSchemaMigration) TableName() string {
return "local_schema_migrations"
}
type localMigration struct {
id string
name string
run func(tx *gorm.DB) error
}
var localMigrations = []localMigration{
{
id: "2026_02_04_versioning_backfill",
name: "Ensure configuration versioning data and current pointers",
run: backfillConfigurationVersions,
},
{
id: "2026_02_04_is_active_backfill",
name: "Ensure is_active defaults to true for existing configurations",
run: backfillConfigurationIsActive,
},
{
id: "2026_02_06_projects_backfill",
name: "Create default projects and attach existing configurations",
run: backfillProjectsForConfigurations,
},
{
id: "2026_02_06_pricelist_backfill",
name: "Attach existing configurations to latest local pricelist and recalc usage",
run: backfillConfigurationPricelists,
},
{
id: "2026_02_06_pricelist_index_fix",
name: "Use unique server_id for local pricelists and allow duplicate versions",
run: fixLocalPricelistIndexes,
},
{
id: "2026_02_06_pricelist_source",
name: "Backfill source for local pricelists and create source indexes",
run: backfillLocalPricelistSource,
},
}
func runLocalMigrations(db *gorm.DB) error {
if err := db.AutoMigrate(&LocalSchemaMigration{}); err != nil {
return fmt.Errorf("migrate local schema migrations table: %w", err)
}
for _, migration := range localMigrations {
var count int64
if err := db.Model(&LocalSchemaMigration{}).Where("id = ?", migration.id).Count(&count).Error; err != nil {
return fmt.Errorf("check local migration %s: %w", migration.id, err)
}
if count > 0 {
continue
}
if err := db.Transaction(func(tx *gorm.DB) error {
if err := migration.run(tx); err != nil {
return fmt.Errorf("run migration %s: %w", migration.id, err)
}
record := &LocalSchemaMigration{
ID: migration.id,
Name: migration.name,
AppliedAt: time.Now(),
}
if err := tx.Create(record).Error; err != nil {
return fmt.Errorf("insert migration %s record: %w", migration.id, err)
}
return nil
}); err != nil {
return err
}
slog.Info("local migration applied", "id", migration.id, "name", migration.name)
}
return nil
}
func backfillConfigurationVersions(tx *gorm.DB) error {
var configs []LocalConfiguration
if err := tx.Find(&configs).Error; err != nil {
return fmt.Errorf("load local configurations for backfill: %w", err)
}
for i := range configs {
cfg := configs[i]
var versionCount int64
if err := tx.Model(&LocalConfigurationVersion{}).
Where("configuration_uuid = ?", cfg.UUID).
Count(&versionCount).Error; err != nil {
return fmt.Errorf("count versions for %s: %w", cfg.UUID, err)
}
if versionCount == 0 {
snapshot, err := BuildConfigurationSnapshot(&cfg)
if err != nil {
return fmt.Errorf("build initial snapshot for %s: %w", cfg.UUID, err)
}
note := "Initial snapshot backfill (v1)"
version := LocalConfigurationVersion{
ID: uuid.NewString(),
ConfigurationUUID: cfg.UUID,
VersionNo: 1,
Data: snapshot,
ChangeNote: &note,
AppVersion: "backfill",
CreatedAt: chooseNonZeroTime(cfg.CreatedAt, time.Now()),
}
if err := tx.Create(&version).Error; err != nil {
return fmt.Errorf("create v1 backfill for %s: %w", cfg.UUID, err)
}
}
if cfg.CurrentVersionID == nil || *cfg.CurrentVersionID == "" {
var latest LocalConfigurationVersion
if err := tx.Where("configuration_uuid = ?", cfg.UUID).
Order("version_no DESC").
First(&latest).Error; err != nil {
return fmt.Errorf("load latest version for %s: %w", cfg.UUID, err)
}
if err := tx.Model(&LocalConfiguration{}).
Where("uuid = ?", cfg.UUID).
Update("current_version_id", latest.ID).Error; err != nil {
return fmt.Errorf("set current version for %s: %w", cfg.UUID, err)
}
}
}
return nil
}
func backfillConfigurationIsActive(tx *gorm.DB) error {
return tx.Exec("UPDATE local_configurations SET is_active = 1 WHERE is_active IS NULL").Error
}
func backfillProjectsForConfigurations(tx *gorm.DB) error {
var owners []string
if err := tx.Model(&LocalConfiguration{}).
Distinct("original_username").
Pluck("original_username", &owners).Error; err != nil {
return fmt.Errorf("load owners for projects backfill: %w", err)
}
for _, owner := range owners {
project, err := ensureDefaultProjectTx(tx, owner)
if err != nil {
return err
}
if err := tx.Model(&LocalConfiguration{}).
Where("original_username = ? AND (project_uuid IS NULL OR project_uuid = '')", owner).
Update("project_uuid", project.UUID).Error; err != nil {
return fmt.Errorf("assign default project for owner %s: %w", owner, err)
}
}
return nil
}
func ensureDefaultProjectTx(tx *gorm.DB, ownerUsername string) (*LocalProject, error) {
var project LocalProject
err := tx.Where("owner_username = ? AND is_system = ? AND name = ?", ownerUsername, true, "Без проекта").
First(&project).Error
if err == nil {
return &project, nil
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("load system project for %s: %w", ownerUsername, err)
}
now := time.Now()
project = LocalProject{
UUID: uuid.NewString(),
OwnerUsername: ownerUsername,
Name: "Без проекта",
IsActive: true,
IsSystem: true,
CreatedAt: now,
UpdatedAt: now,
SyncStatus: "pending",
}
if err := tx.Create(&project).Error; err != nil {
return nil, fmt.Errorf("create system project for %s: %w", ownerUsername, err)
}
return &project, 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 {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return fmt.Errorf("load latest local pricelist: %w", err)
}
if err := tx.Model(&LocalConfiguration{}).
Where("pricelist_id IS NULL").
Update("pricelist_id", latest.ServerID).Error; err != nil {
return fmt.Errorf("backfill configuration pricelist_id: %w", err)
}
if err := tx.Model(&LocalPricelist{}).Where("1 = 1").Update("is_used", false).Error; err != nil {
return fmt.Errorf("reset local pricelist usage flags: %w", err)
}
if err := tx.Exec(`
UPDATE local_pricelists
SET is_used = 1
WHERE server_id IN (
SELECT DISTINCT pricelist_id
FROM local_configurations
WHERE pricelist_id IS NOT NULL AND is_active = 1
)
`).Error; err != nil {
return fmt.Errorf("recalculate local pricelist usage flags: %w", err)
}
return nil
}
func chooseNonZeroTime(candidate time.Time, fallback time.Time) time.Time {
if candidate.IsZero() {
return fallback
}
return candidate
}
func fixLocalPricelistIndexes(tx *gorm.DB) error {
type indexRow struct {
Name string `gorm:"column:name"`
Unique int `gorm:"column:unique"`
}
var indexes []indexRow
if err := tx.Raw("PRAGMA index_list('local_pricelists')").Scan(&indexes).Error; err != nil {
return fmt.Errorf("list local_pricelists indexes: %w", err)
}
for _, idx := range indexes {
if idx.Unique == 0 {
continue
}
type indexInfoRow struct {
Name string `gorm:"column:name"`
}
var info []indexInfoRow
if err := tx.Raw(fmt.Sprintf("PRAGMA index_info('%s')", strings.ReplaceAll(idx.Name, "'", "''"))).Scan(&info).Error; err != nil {
return fmt.Errorf("load index info for %s: %w", idx.Name, err)
}
if len(info) != 1 || info[0].Name != "version" {
continue
}
quoted := strings.ReplaceAll(idx.Name, `"`, `""`)
if err := tx.Exec(fmt.Sprintf(`DROP INDEX IF EXISTS "%s"`, quoted)).Error; err != nil {
return fmt.Errorf("drop unique version index %s: %w", idx.Name, err)
}
}
if err := tx.Exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_local_pricelists_server_id
ON local_pricelists(server_id)
`).Error; err != nil {
return fmt.Errorf("ensure unique index local_pricelists(server_id): %w", err)
}
if err := tx.Exec(`
CREATE INDEX IF NOT EXISTS idx_local_pricelists_version
ON local_pricelists(version)
`).Error; err != nil {
return fmt.Errorf("ensure index local_pricelists(version): %w", err)
}
return nil
}
func backfillLocalPricelistSource(tx *gorm.DB) error {
if err := tx.Exec(`
UPDATE local_pricelists
SET source = 'estimate'
WHERE source IS NULL OR source = ''
`).Error; err != nil {
return fmt.Errorf("backfill local_pricelists.source: %w", err)
}
if err := tx.Exec(`
CREATE INDEX IF NOT EXISTS idx_local_pricelists_source_created_at
ON local_pricelists(source, created_at DESC)
`).Error; err != nil {
return fmt.Errorf("ensure idx_local_pricelists_source_created_at: %w", err)
}
return nil
}

View File

@@ -59,37 +59,81 @@ func (c LocalConfigItems) Total() float64 {
// 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
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"`
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
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"`
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
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 {
return "local_configurations"
}
type LocalProject struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
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"`
TrackerURL string `json:"tracker_url"`
IsActive bool `gorm:"default:true;index" json:"is_active"`
IsSystem bool `gorm:"default:false;index" json:"is_system"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
SyncedAt *time.Time `json:"synced_at,omitempty"`
SyncStatus string `gorm:"default:'local'" json:"sync_status"` // local/synced/pending
}
func (LocalProject) TableName() string {
return "local_projects"
}
// LocalConfigurationVersion stores immutable full snapshots for each configuration version
type LocalConfigurationVersion struct {
ID string `gorm:"primaryKey" json:"id"`
ConfigurationUUID string `gorm:"not null;index:idx_lcv_config_created,priority:1;index:idx_lcv_config_version,priority:1;uniqueIndex:idx_lcv_config_version_unique,priority:1" json:"configuration_uuid"`
VersionNo int `gorm:"not null;index:idx_lcv_config_version,sort:desc,priority:2;uniqueIndex:idx_lcv_config_version_unique,priority:2" json:"version_no"`
Data string `gorm:"type:text;not null" json:"data"`
ChangeNote *string `json:"change_note,omitempty"`
CreatedBy *string `json:"created_by,omitempty"`
AppVersion string `gorm:"size:64" json:"app_version,omitempty"`
CreatedAt time.Time `gorm:"not null;autoCreateTime;index:idx_lcv_config_created,sort:desc,priority:2" json:"created_at"`
Configuration *LocalConfiguration `gorm:"foreignKey:ConfigurationUUID;references:UUID" json:"configuration,omitempty"`
}
func (LocalConfigurationVersion) TableName() string {
return "local_configuration_versions"
}
// LocalPricelist stores cached pricelists from server
type LocalPricelist struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
ServerID uint `gorm:"not null" json:"server_id"` // ID on MariaDB server
Version string `gorm:"uniqueIndex;not null" json:"version"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
SyncedAt time.Time `json:"synced_at"`
IsUsed bool `gorm:"default:false" json:"is_used"` // Used by any local configuration
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
ServerID uint `gorm:"not null;uniqueIndex" json:"server_id"` // ID on MariaDB server
Source string `gorm:"not null;default:'estimate';index:idx_local_pricelists_source_created_at,priority:1" json:"source"`
Version string `gorm:"not null;index" json:"version"`
Name string `json:"name"`
CreatedAt time.Time `gorm:"index:idx_local_pricelists_source_created_at,priority:2,sort:desc" json:"created_at"`
SyncedAt time.Time `json:"synced_at"`
IsUsed bool `gorm:"default:false" json:"is_used"` // Used by any local configuration
}
func (LocalPricelist) TableName() string {
@@ -127,7 +171,7 @@ type PendingChange struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
EntityType string `gorm:"not null;index" json:"entity_type"` // "configuration", "project", "specification"
EntityUUID string `gorm:"not null;index" json:"entity_uuid"`
Operation string `gorm:"not null" json:"operation"` // "create", "update", "delete"
Operation string `gorm:"not null" json:"operation"` // "create", "update", "rollback", "deactivate", "reactivate", "delete"
Payload string `gorm:"type:text" json:"payload"` // JSON snapshot of the entity
CreatedAt time.Time `gorm:"not null" json:"created_at"`
Attempts int `gorm:"default:0" json:"attempts"` // Retry count for sync

View File

@@ -0,0 +1,84 @@
package localdb
import (
"encoding/json"
"fmt"
"time"
)
// BuildConfigurationSnapshot serializes the full local configuration state.
func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
snapshot := map[string]interface{}{
"id": localCfg.ID,
"uuid": localCfg.UUID,
"server_id": localCfg.ServerID,
"project_uuid": localCfg.ProjectUUID,
"current_version_id": localCfg.CurrentVersionID,
"is_active": localCfg.IsActive,
"name": localCfg.Name,
"items": localCfg.Items,
"total_price": localCfg.TotalPrice,
"custom_price": localCfg.CustomPrice,
"notes": localCfg.Notes,
"is_template": localCfg.IsTemplate,
"server_count": localCfg.ServerCount,
"pricelist_id": localCfg.PricelistID,
"price_updated_at": localCfg.PriceUpdatedAt,
"created_at": localCfg.CreatedAt,
"updated_at": localCfg.UpdatedAt,
"synced_at": localCfg.SyncedAt,
"sync_status": localCfg.SyncStatus,
"original_user_id": localCfg.OriginalUserID,
"original_username": localCfg.OriginalUsername,
}
data, err := json.Marshal(snapshot)
if err != nil {
return "", fmt.Errorf("marshal configuration snapshot: %w", err)
}
return string(data), nil
}
// DecodeConfigurationSnapshot returns editable fields from one saved snapshot.
func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
var snapshot struct {
ProjectUUID *string `json:"project_uuid"`
IsActive *bool `json:"is_active"`
Name string `json:"name"`
Items LocalConfigItems `json:"items"`
TotalPrice *float64 `json:"total_price"`
CustomPrice *float64 `json:"custom_price"`
Notes string `json:"notes"`
IsTemplate bool `json:"is_template"`
ServerCount int `json:"server_count"`
PricelistID *uint `json:"pricelist_id"`
PriceUpdatedAt *time.Time `json:"price_updated_at"`
OriginalUserID uint `json:"original_user_id"`
OriginalUsername string `json:"original_username"`
}
if err := json.Unmarshal([]byte(data), &snapshot); err != nil {
return nil, fmt.Errorf("unmarshal snapshot JSON: %w", err)
}
isActive := true
if snapshot.IsActive != nil {
isActive = *snapshot.IsActive
}
return &LocalConfiguration{
IsActive: isActive,
ProjectUUID: snapshot.ProjectUUID,
Name: snapshot.Name,
Items: snapshot.Items,
TotalPrice: snapshot.TotalPrice,
CustomPrice: snapshot.CustomPrice,
Notes: snapshot.Notes,
IsTemplate: snapshot.IsTemplate,
ServerCount: snapshot.ServerCount,
PricelistID: snapshot.PricelistID,
PriceUpdatedAt: snapshot.PriceUpdatedAt,
OriginalUserID: snapshot.OriginalUserID,
OriginalUsername: snapshot.OriginalUsername,
}, nil
}

View File

@@ -4,9 +4,9 @@ import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/gin-gonic/gin"
)
const (
@@ -99,3 +99,12 @@ func GetUserID(c *gin.Context) uint {
}
return claims.UserID
}
// GetUsername extracts username from context
func GetUsername(c *gin.Context) string {
claims := GetClaims(c)
if claims == nil {
return ""
}
return claims.Username
}

View File

@@ -40,18 +40,25 @@ func (c ConfigItems) Total() float64 {
}
type Configuration struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
UserID uint `gorm:"not null" json:"user_id"`
Name string `gorm:"size:200;not null" json:"name"`
Items ConfigItems `gorm:"type:json;not null" json:"items"`
TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"`
CustomPrice *float64 `gorm:"type:decimal(12,2)" json:"custom_price"`
Notes string `gorm:"type:text" json:"notes"`
IsTemplate bool `gorm:"default:false" json:"is_template"`
ServerCount int `gorm:"default:1" json:"server_count"`
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
UserID *uint `json:"user_id,omitempty"` // Legacy field, no longer required for ownership
OwnerUsername string `gorm:"size:100;not null;default:'';index" json:"owner_username"`
ProjectUUID *string `gorm:"size:36;index" json:"project_uuid,omitempty"`
AppVersion string `gorm:"size:64" json:"app_version,omitempty"`
Name string `gorm:"size:200;not null" json:"name"`
Items ConfigItems `gorm:"type:json;not null" json:"items"`
TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"`
CustomPrice *float64 `gorm:"type:decimal(12,2)" json:"custom_price"`
Notes string `gorm:"type:text" json:"notes"`
IsTemplate bool `gorm:"default:false" json:"is_template"`
ServerCount int `gorm:"default:1" json:"server_count"`
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"`
DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"`
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
}

View File

@@ -13,6 +13,7 @@ func AllModels() []interface{} {
&User{},
&Category{},
&LotMetadata{},
&Project{},
&Configuration{},
&PriceOverride{},
&PricingAlert{},

View File

@@ -4,12 +4,41 @@ import (
"time"
)
type PricelistSource string
const (
PricelistSourceEstimate PricelistSource = "estimate"
PricelistSourceWarehouse PricelistSource = "warehouse"
PricelistSourceCompetitor PricelistSource = "competitor"
)
func (s PricelistSource) IsValid() bool {
switch s {
case PricelistSourceEstimate, PricelistSourceWarehouse, PricelistSourceCompetitor:
return true
default:
return false
}
}
func NormalizePricelistSource(source string) PricelistSource {
switch PricelistSource(source) {
case PricelistSourceWarehouse:
return PricelistSourceWarehouse
case PricelistSourceCompetitor:
return PricelistSourceCompetitor
default:
return PricelistSourceEstimate
}
}
// Pricelist represents a versioned snapshot of prices
type Pricelist struct {
ID uint `gorm:"primaryKey" json:"id"`
Version string `gorm:"size:20;uniqueIndex;not null" json:"version"` // Format: YYYY-MM-DD-NNN
Notification string `gorm:"size:500" json:"notification"` // Notification shown in configurator
CreatedAt time.Time `json:"created_at"`
Source string `gorm:"size:20;not null;default:'estimate';uniqueIndex:idx_qt_pricelists_source_version,priority:1;index:idx_qt_pricelists_source_created_at,priority:1" json:"source"`
Version string `gorm:"size:20;not null;uniqueIndex:idx_qt_pricelists_source_version,priority:2" json:"version"` // Format: YYYY-MM-DD-NNN
Notification string `gorm:"size:500" json:"notification"` // Notification shown in configurator
CreatedAt time.Time `gorm:"index:idx_qt_pricelists_source_created_at,priority:2,sort:desc" json:"created_at"`
CreatedBy string `gorm:"size:100" json:"created_by"`
IsActive bool `gorm:"default:true" json:"is_active"`
UsageCount int `gorm:"default:0" json:"usage_count"`
@@ -47,6 +76,7 @@ func (PricelistItem) TableName() string {
// PricelistSummary is used for list views
type PricelistSummary struct {
ID uint `json:"id"`
Source string `json:"source"`
Version string `json:"version"`
Notification string `json:"notification"`
CreatedAt time.Time `json:"created_at"`

View File

@@ -0,0 +1,19 @@
package models
import "time"
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"`
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"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
}
func (Project) TableName() string {
return "qt_projects"
}

View File

@@ -0,0 +1,227 @@
package models
import (
"bufio"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
mysqlDriver "github.com/go-sql-driver/mysql"
"gorm.io/gorm"
)
type SQLSchemaMigration struct {
ID uint `gorm:"primaryKey;autoIncrement"`
Filename string `gorm:"size:255;uniqueIndex;not null"`
AppliedAt time.Time `gorm:"autoCreateTime"`
}
func (SQLSchemaMigration) TableName() string {
return "qt_schema_migrations"
}
// NeedsSQLMigrations reports whether at least one SQL migration from migrationsDir
// is not yet recorded in qt_schema_migrations.
func NeedsSQLMigrations(db *gorm.DB, migrationsDir string) (bool, error) {
files, err := listSQLMigrationFiles(migrationsDir)
if err != nil {
return false, err
}
if len(files) == 0 {
return false, nil
}
// If tracking table does not exist yet, migrations are required.
if !db.Migrator().HasTable(&SQLSchemaMigration{}) {
return true, nil
}
var count int64
if err := db.Model(&SQLSchemaMigration{}).Where("filename IN ?", files).Count(&count).Error; err != nil {
return false, fmt.Errorf("check applied migrations: %w", err)
}
return count < int64(len(files)), nil
}
// RunSQLMigrations applies SQL files from migrationsDir once and records them in qt_schema_migrations.
// Local SQLite-only scripts are skipped automatically.
func RunSQLMigrations(db *gorm.DB, migrationsDir string) error {
if err := ensureSQLMigrationsTable(db); err != nil {
return fmt.Errorf("migrate qt_schema_migrations table: %w", err)
}
files, err := listSQLMigrationFiles(migrationsDir)
if err != nil {
return err
}
for _, filename := range files {
var count int64
if err := db.Model(&SQLSchemaMigration{}).Where("filename = ?", filename).Count(&count).Error; err != nil {
return fmt.Errorf("check migration %s: %w", filename, err)
}
if count > 0 {
continue
}
path := filepath.Join(migrationsDir, filename)
content, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read migration %s: %w", filename, err)
}
statements := splitSQLStatements(string(content))
if len(statements) == 0 {
if err := db.Create(&SQLSchemaMigration{Filename: filename}).Error; err != nil {
return fmt.Errorf("record empty migration %s: %w", filename, err)
}
continue
}
if err := executeMigrationStatements(db, filename, statements); err != nil {
return err
}
if err := db.Create(&SQLSchemaMigration{Filename: filename}).Error; err != nil {
return fmt.Errorf("record migration %s: %w", filename, err)
}
}
return nil
}
// IsMigrationPermissionError returns true if err indicates insufficient privileges
// to create/alter/read migration metadata or target schema objects.
func IsMigrationPermissionError(err error) bool {
if err == nil {
return false
}
var mysqlErr *mysqlDriver.MySQLError
if errors.As(err, &mysqlErr) {
switch mysqlErr.Number {
case 1044, 1045, 1142, 1143, 1227:
return true
}
}
lower := strings.ToLower(err.Error())
patterns := []string{
"command denied to user",
"access denied for user",
"permission denied",
"insufficient privilege",
"sqlstate 42000",
}
for _, pattern := range patterns {
if strings.Contains(lower, pattern) {
return true
}
}
return false
}
func ensureSQLMigrationsTable(db *gorm.DB) error {
stmt := `
CREATE TABLE IF NOT EXISTS qt_schema_migrations (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
filename VARCHAR(255) NOT NULL UNIQUE,
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);`
return db.Exec(stmt).Error
}
func executeMigrationStatements(db *gorm.DB, filename string, statements []string) error {
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
if isIgnorableMigrationError(err.Error()) {
continue
}
return fmt.Errorf("exec migration %s statement %q: %w", filename, stmt, err)
}
}
return nil
}
func isSQLiteOnlyMigration(filename string) bool {
lower := strings.ToLower(filename)
return strings.Contains(lower, "local_")
}
func isIgnorableMigrationError(message string) bool {
lower := strings.ToLower(message)
ignorable := []string{
"duplicate column name",
"duplicate key name",
"already exists",
"can't create table",
"duplicate foreign key constraint name",
"errno 121",
}
for _, pattern := range ignorable {
if strings.Contains(lower, pattern) {
return true
}
}
return false
}
func splitSQLStatements(script string) []string {
scanner := bufio.NewScanner(strings.NewReader(script))
scanner.Buffer(make([]byte, 1024), 1024*1024)
lines := make([]string, 0, 128)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
if strings.HasPrefix(line, "--") {
continue
}
lines = append(lines, scanner.Text())
}
combined := strings.Join(lines, "\n")
raw := strings.Split(combined, ";")
stmts := make([]string, 0, len(raw))
for _, stmt := range raw {
trimmed := strings.TrimSpace(stmt)
if trimmed == "" {
continue
}
stmts = append(stmts, trimmed)
}
return stmts
}
func listSQLMigrationFiles(migrationsDir string) ([]string, error) {
entries, err := os.ReadDir(migrationsDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, fmt.Errorf("read migrations dir %s: %w", migrationsDir, err)
}
files := make([]string, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasSuffix(strings.ToLower(name), ".sql") {
continue
}
if isSQLiteOnlyMigration(name) {
continue
}
files = append(files, name)
}
sort.Strings(files)
return files, nil
}

View File

@@ -110,6 +110,10 @@ func (r *ComponentRepository) Update(component *models.LotMetadata) error {
return r.db.Save(component).Error
}
func (r *ComponentRepository) DB() *gorm.DB {
return r.db
}
func (r *ComponentRepository) Create(component *models.LotMetadata) error {
return r.db.Create(component).Error
}

View File

@@ -19,7 +19,7 @@ func (r *ConfigurationRepository) Create(config *models.Configuration) error {
func (r *ConfigurationRepository) GetByID(id uint) (*models.Configuration, error) {
var config models.Configuration
err := r.db.Preload("User").First(&config, id).Error
err := r.db.First(&config, id).Error
if err != nil {
return nil, err
}
@@ -28,7 +28,7 @@ func (r *ConfigurationRepository) GetByID(id uint) (*models.Configuration, error
func (r *ConfigurationRepository) GetByUUID(uuid string) (*models.Configuration, error) {
var config models.Configuration
err := r.db.Preload("User").Where("uuid = ?", uuid).First(&config).Error
err := r.db.Where("uuid = ?", uuid).First(&config).Error
if err != nil {
return nil, err
}
@@ -43,13 +43,15 @@ func (r *ConfigurationRepository) Delete(id uint) error {
return r.db.Delete(&models.Configuration{}, id).Error
}
func (r *ConfigurationRepository) ListByUser(userID uint, offset, limit int) ([]models.Configuration, int64, error) {
func (r *ConfigurationRepository) ListByUser(ownerUsername string, offset, limit int) ([]models.Configuration, int64, error) {
var configs []models.Configuration
var total int64
r.db.Model(&models.Configuration{}).Where("user_id = ?", userID).Count(&total)
ownerScope := "owner_username = ?"
r.db.Model(&models.Configuration{}).Where(ownerScope, ownerUsername).Count(&total)
err := r.db.
Where("user_id = ?", userID).
Where(ownerScope, ownerUsername).
Order("created_at DESC").
Offset(offset).
Limit(limit).
@@ -64,7 +66,6 @@ func (r *ConfigurationRepository) ListTemplates(offset, limit int) ([]models.Con
r.db.Model(&models.Configuration{}).Where("is_template = ?", true).Count(&total)
err := r.db.
Preload("User").
Where("is_template = ?", true).
Order("created_at DESC").
Offset(offset).

View File

@@ -1,7 +1,9 @@
package repository
import (
"errors"
"fmt"
"strconv"
"strings"
"time"
@@ -19,36 +21,86 @@ func NewPricelistRepository(db *gorm.DB) *PricelistRepository {
// List returns pricelists with pagination
func (r *PricelistRepository) List(offset, limit int) ([]models.PricelistSummary, int64, error) {
return r.ListBySource("", offset, limit)
}
// ListBySource returns pricelists filtered by source when provided.
func (r *PricelistRepository) ListBySource(source string, offset, limit int) ([]models.PricelistSummary, int64, error) {
query := r.db.Model(&models.Pricelist{})
if source != "" {
query = query.Where("source = ?", source)
}
var total int64
if err := r.db.Model(&models.Pricelist{}).Count(&total).Error; err != nil {
if err := query.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("counting pricelists: %w", err)
}
var pricelists []models.Pricelist
if err := r.db.Order("created_at DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
return nil, 0, fmt.Errorf("listing pricelists: %w", err)
}
return r.toSummaries(pricelists), total, nil
}
// ListActive returns active pricelists with pagination.
func (r *PricelistRepository) ListActive(offset, limit int) ([]models.PricelistSummary, int64, error) {
return r.ListActiveBySource("", offset, limit)
}
// ListActiveBySource returns active pricelists filtered by source when provided.
func (r *PricelistRepository) ListActiveBySource(source string, offset, limit int) ([]models.PricelistSummary, int64, error) {
query := r.db.Model(&models.Pricelist{}).Where("is_active = ?", true)
if source != "" {
query = query.Where("source = ?", source)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("counting active pricelists: %w", err)
}
var pricelists []models.Pricelist
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
return nil, 0, fmt.Errorf("listing active pricelists: %w", err)
}
return r.toSummaries(pricelists), total, nil
}
// CountActive returns the number of active pricelists.
func (r *PricelistRepository) CountActive() (int64, error) {
var total int64
if err := r.db.Model(&models.Pricelist{}).Where("is_active = ?", true).Count(&total).Error; err != nil {
return 0, fmt.Errorf("counting active pricelists: %w", err)
}
return total, nil
}
func (r *PricelistRepository) toSummaries(pricelists []models.Pricelist) []models.PricelistSummary {
// Get item counts for each pricelist
summaries := make([]models.PricelistSummary, len(pricelists))
for i, pl := range pricelists {
var itemCount int64
r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", pl.ID).Count(&itemCount)
usageCount, _ := r.CountUsage(pl.ID)
summaries[i] = models.PricelistSummary{
ID: pl.ID,
Source: pl.Source,
Version: pl.Version,
Notification: pl.Notification,
CreatedAt: pl.CreatedAt,
CreatedBy: pl.CreatedBy,
IsActive: pl.IsActive,
UsageCount: pl.UsageCount,
UsageCount: int(usageCount),
ExpiresAt: pl.ExpiresAt,
ItemCount: itemCount,
}
}
return summaries, total, nil
return summaries
}
// GetByID returns a pricelist by ID
@@ -62,14 +114,22 @@ func (r *PricelistRepository) GetByID(id uint) (*models.Pricelist, error) {
var itemCount int64
r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", id).Count(&itemCount)
pricelist.ItemCount = int(itemCount)
if usageCount, err := r.CountUsage(id); err == nil {
pricelist.UsageCount = int(usageCount)
}
return &pricelist, nil
}
// GetByVersion returns a pricelist by version string
func (r *PricelistRepository) GetByVersion(version string) (*models.Pricelist, error) {
return r.GetBySourceAndVersion(string(models.PricelistSourceEstimate), version)
}
// GetBySourceAndVersion returns a pricelist by source/version.
func (r *PricelistRepository) GetBySourceAndVersion(source, version string) (*models.Pricelist, error) {
var pricelist models.Pricelist
if err := r.db.Where("version = ?", version).First(&pricelist).Error; err != nil {
if err := r.db.Where("source = ? AND version = ?", source, version).First(&pricelist).Error; err != nil {
return nil, fmt.Errorf("getting pricelist by version: %w", err)
}
return &pricelist, nil
@@ -77,8 +137,13 @@ func (r *PricelistRepository) GetByVersion(version string) (*models.Pricelist, e
// GetLatestActive returns the most recent active pricelist
func (r *PricelistRepository) GetLatestActive() (*models.Pricelist, error) {
return r.GetLatestActiveBySource(string(models.PricelistSourceEstimate))
}
// GetLatestActiveBySource returns the most recent active pricelist by source.
func (r *PricelistRepository) GetLatestActiveBySource(source string) (*models.Pricelist, error) {
var pricelist models.Pricelist
if err := r.db.Where("is_active = ?", true).Order("created_at DESC").First(&pricelist).Error; err != nil {
if err := r.db.Where("is_active = ? AND source = ?", true, source).Order("created_at DESC").First(&pricelist).Error; err != nil {
return nil, fmt.Errorf("getting latest pricelist: %w", err)
}
return &pricelist, nil
@@ -102,13 +167,13 @@ func (r *PricelistRepository) Update(pricelist *models.Pricelist) error {
// Delete deletes a pricelist if usage_count is 0
func (r *PricelistRepository) Delete(id uint) error {
pricelist, err := r.GetByID(id)
usageCount, err := r.CountUsage(id)
if err != nil {
return err
}
if pricelist.UsageCount > 0 {
return fmt.Errorf("cannot delete pricelist with usage_count > 0 (current: %d)", pricelist.UsageCount)
if usageCount > 0 {
return fmt.Errorf("cannot delete pricelist with usage_count > 0 (current: %d)", usageCount)
}
// Delete items first
@@ -178,18 +243,67 @@ func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, sear
return items, total, nil
}
// GetPriceForLot returns item price for a lot within a pricelist.
func (r *PricelistRepository) GetPriceForLot(pricelistID uint, lotName string) (float64, error) {
var item models.PricelistItem
if err := r.db.Where("pricelist_id = ? AND lot_name = ?", pricelistID, lotName).First(&item).Error; err != nil {
return 0, err
}
return item.Price, nil
}
// SetActive toggles active flag on a pricelist.
func (r *PricelistRepository) SetActive(id uint, isActive bool) error {
return r.db.Model(&models.Pricelist{}).Where("id = ?", id).Update("is_active", isActive).Error
}
// GenerateVersion generates a new version string in format YYYY-MM-DD-NNN
func (r *PricelistRepository) GenerateVersion() (string, error) {
return r.GenerateVersionBySource(string(models.PricelistSourceEstimate))
}
// GenerateVersionBySource generates a new version string in format YYYY-MM-DD-NNN scoped by source.
func (r *PricelistRepository) GenerateVersionBySource(source string) (string, error) {
today := time.Now().Format("2006-01-02")
var count int64
if err := r.db.Model(&models.Pricelist{}).
Where("version LIKE ?", today+"%").
Count(&count).Error; err != nil {
return "", fmt.Errorf("counting today's pricelists: %w", err)
var last models.Pricelist
err := r.db.Model(&models.Pricelist{}).
Select("version").
Where("source = ? AND version LIKE ?", source, today+"-%").
Order("version DESC").
Limit(1).
Take(&last).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Sprintf("%s-001", today), nil
}
return "", fmt.Errorf("loading latest today's pricelist version: %w", err)
}
return fmt.Sprintf("%s-%03d", today, count+1), nil
parts := strings.Split(last.Version, "-")
if len(parts) < 4 {
return "", fmt.Errorf("invalid pricelist version format: %s", last.Version)
}
n, err := strconv.Atoi(parts[len(parts)-1])
if err != nil {
return "", fmt.Errorf("parsing pricelist sequence %q: %w", parts[len(parts)-1], err)
}
return fmt.Sprintf("%s-%03d", today, n+1), nil
}
// GetPriceForLotBySource returns item price for a lot from latest active pricelist of source.
func (r *PricelistRepository) GetPriceForLotBySource(source, lotName string) (float64, uint, error) {
latest, err := r.GetLatestActiveBySource(source)
if err != nil {
return 0, 0, err
}
price, err := r.GetPriceForLot(latest.ID, lotName)
if err != nil {
return 0, 0, err
}
return price, latest.ID, nil
}
// CanWrite checks if the current database user has INSERT permission on qt_pricelists
@@ -248,6 +362,15 @@ func (r *PricelistRepository) DecrementUsageCount(id uint) error {
UpdateColumn("usage_count", gorm.Expr("GREATEST(usage_count - 1, 0)")).Error
}
// CountUsage returns number of configurations referencing pricelist.
func (r *PricelistRepository) CountUsage(id uint) (int64, error) {
var count int64
if err := r.db.Table("qt_configurations").Where("pricelist_id = ?", id).Count(&count).Error; err != nil {
return 0, fmt.Errorf("counting configurations for pricelist %d: %w", id, err)
}
return count, nil
}
// GetExpiredUnused returns pricelists that are expired and unused
func (r *PricelistRepository) GetExpiredUnused() ([]models.Pricelist, error) {
var pricelists []models.Pricelist

View File

@@ -0,0 +1,89 @@
package repository
import (
"fmt"
"testing"
"time"
"git.mchus.pro/mchus/quoteforge/internal/models"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestGenerateVersion_FirstOfDay(t *testing.T) {
repo := newTestPricelistRepository(t)
version, err := repo.GenerateVersionBySource(string(models.PricelistSourceEstimate))
if err != nil {
t.Fatalf("GenerateVersionBySource returned error: %v", err)
}
today := time.Now().Format("2006-01-02")
want := fmt.Sprintf("%s-001", today)
if version != want {
t.Fatalf("expected %s, got %s", want, version)
}
}
func TestGenerateVersion_UsesMaxSuffixNotCount(t *testing.T) {
repo := newTestPricelistRepository(t)
today := time.Now().Format("2006-01-02")
seed := []models.Pricelist{
{Source: string(models.PricelistSourceEstimate), Version: fmt.Sprintf("%s-001", today), CreatedBy: "test", IsActive: true},
{Source: string(models.PricelistSourceEstimate), Version: fmt.Sprintf("%s-003", today), CreatedBy: "test", IsActive: true},
}
for _, pl := range seed {
if err := repo.Create(&pl); err != nil {
t.Fatalf("seed insert failed: %v", err)
}
}
version, err := repo.GenerateVersionBySource(string(models.PricelistSourceEstimate))
if err != nil {
t.Fatalf("GenerateVersionBySource returned error: %v", err)
}
want := fmt.Sprintf("%s-004", today)
if version != want {
t.Fatalf("expected %s, got %s", want, version)
}
}
func TestGenerateVersion_IsolatedBySource(t *testing.T) {
repo := newTestPricelistRepository(t)
today := time.Now().Format("2006-01-02")
seed := []models.Pricelist{
{Source: string(models.PricelistSourceEstimate), Version: fmt.Sprintf("%s-009", today), CreatedBy: "test", IsActive: true},
{Source: string(models.PricelistSourceWarehouse), Version: fmt.Sprintf("%s-002", today), CreatedBy: "test", IsActive: true},
}
for _, pl := range seed {
if err := repo.Create(&pl); err != nil {
t.Fatalf("seed insert failed: %v", err)
}
}
version, err := repo.GenerateVersionBySource(string(models.PricelistSourceWarehouse))
if err != nil {
t.Fatalf("GenerateVersionBySource returned error: %v", err)
}
want := fmt.Sprintf("%s-003", today)
if version != want {
t.Fatalf("expected %s, got %s", want, version)
}
}
func newTestPricelistRepository(t *testing.T) *PricelistRepository {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.Pricelist{}); err != nil {
t.Fatalf("migrate: %v", err)
}
return NewPricelistRepository(db)
}

View File

@@ -0,0 +1,194 @@
package repository
import (
"git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type ProjectRepository struct {
db *gorm.DB
}
func NewProjectRepository(db *gorm.DB) *ProjectRepository {
return &ProjectRepository{db: db}
}
func (r *ProjectRepository) Create(project *models.Project) error {
return r.db.Create(project).Error
}
func (r *ProjectRepository) Update(project *models.Project) error {
return r.db.Save(project).Error
}
func (r *ProjectRepository) UpsertByUUID(project *models.Project) error {
if err := r.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "uuid"}},
DoUpdates: clause.AssignmentColumns([]string{
"owner_username",
"name",
"tracker_url",
"is_active",
"is_system",
"updated_at",
}),
}).Create(project).Error; err != nil {
return err
}
// Ensure caller always gets canonical server ID.
var persisted models.Project
if err := r.db.Where("uuid = ?", project.UUID).First(&persisted).Error; err != nil {
return err
}
project.ID = persisted.ID
return nil
}
func (r *ProjectRepository) GetByUUID(uuid string) (*models.Project, error) {
var project models.Project
if err := r.db.Where("uuid = ?", uuid).First(&project).Error; err != nil {
return nil, err
}
return &project, nil
}
func (r *ProjectRepository) GetSystemByOwner(ownerUsername string) (*models.Project, error) {
var project models.Project
if err := r.db.Where("owner_username = ? AND is_system = ? AND name = ?", ownerUsername, true, "Без проекта").
First(&project).Error; err != nil {
return nil, err
}
return &project, nil
}
func (r *ProjectRepository) List(offset, limit int, includeArchived bool) ([]models.Project, int64, error) {
var projects []models.Project
var total int64
query := r.db.Model(&models.Project{})
if !includeArchived {
query = query.Where("is_active = ?", true)
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&projects).Error; err != nil {
return nil, 0, err
}
return projects, total, nil
}
func (r *ProjectRepository) ListByOwner(ownerUsername string, includeArchived bool) ([]models.Project, error) {
var projects []models.Project
query := r.db.Where("owner_username = ?", ownerUsername)
if !includeArchived {
query = query.Where("is_active = ?", true)
}
if err := query.Order("created_at DESC").Find(&projects).Error; err != nil {
return nil, err
}
return projects, nil
}
func (r *ProjectRepository) Archive(uuid string) error {
return r.db.Model(&models.Project{}).Where("uuid = ?", uuid).Update("is_active", false).Error
}
func (r *ProjectRepository) Reactivate(uuid string) error {
return r.db.Model(&models.Project{}).Where("uuid = ?", uuid).Update("is_active", true).Error
}
// PurgeEmptyNamelessProjects removes service-trash projects that have no configurations attached:
// 1) projects with empty names;
// 2) duplicate "Без проекта" rows without configurations (case-insensitive, trimmed).
func (r *ProjectRepository) PurgeEmptyNamelessProjects() (int64, error) {
tx := r.db.Exec(`
DELETE p
FROM qt_projects p
WHERE (
TRIM(COALESCE(p.name, '')) = ''
OR LOWER(TRIM(COALESCE(p.name, ''))) = LOWER('Без проекта')
)
AND NOT EXISTS (
SELECT 1
FROM qt_configurations c
WHERE c.project_uuid = p.uuid
)`)
return tx.RowsAffected, tx.Error
}
// EnsureSystemProjectsAndBackfillConfigurations ensures there is a single shared system project
// named "Без проекта", reassigns orphan/legacy links to it and removes duplicates.
func (r *ProjectRepository) EnsureSystemProjectsAndBackfillConfigurations() error {
return r.db.Transaction(func(tx *gorm.DB) error {
type row struct {
UUID string `gorm:"column:uuid"`
}
var canonical row
err := tx.Raw(`
SELECT uuid
FROM qt_projects
WHERE LOWER(TRIM(COALESCE(name, ''))) = LOWER('Без проекта')
AND is_system = TRUE
ORDER BY CASE WHEN TRIM(COALESCE(owner_username, '')) = '' THEN 0 ELSE 1 END, created_at ASC, id ASC
LIMIT 1`).Scan(&canonical).Error
if err != nil {
return err
}
if canonical.UUID == "" {
if err := tx.Exec(`
INSERT INTO qt_projects (uuid, owner_username, name, is_active, is_system, created_at, updated_at)
VALUES (UUID(), '', 'Без проекта', TRUE, TRUE, NOW(), NOW())`).Error; err != nil {
return err
}
if err := tx.Raw(`
SELECT uuid
FROM qt_projects
WHERE LOWER(TRIM(COALESCE(name, ''))) = LOWER('Без проекта')
ORDER BY created_at DESC, id DESC
LIMIT 1`).Scan(&canonical).Error; err != nil {
return err
}
if canonical.UUID == "" {
return gorm.ErrRecordNotFound
}
}
if err := tx.Exec(`
UPDATE qt_projects
SET name = 'Без проекта',
is_active = TRUE,
is_system = TRUE
WHERE uuid = ?`, canonical.UUID).Error; err != nil {
return err
}
if err := tx.Exec(`
UPDATE qt_configurations
SET project_uuid = ?
WHERE project_uuid IS NULL OR project_uuid = ''`, canonical.UUID).Error; err != nil {
return err
}
if err := tx.Exec(`
UPDATE qt_configurations c
JOIN qt_projects p ON p.uuid = c.project_uuid
SET c.project_uuid = ?
WHERE LOWER(TRIM(COALESCE(p.name, ''))) = LOWER('Без проекта')
AND p.uuid <> ?`, canonical.UUID, canonical.UUID).Error; err != nil {
return err
}
return tx.Exec(`
DELETE FROM qt_projects
WHERE LOWER(TRIM(COALESCE(name, ''))) = LOWER('Без проекта')
AND uuid <> ?`, canonical.UUID).Error
})
}

View File

@@ -19,7 +19,7 @@ type DataSource interface {
// Configurations
SaveConfiguration(cfg *models.Configuration) error
GetConfigurations(userID uint) ([]models.Configuration, error)
GetConfigurations(ownerUsername string) ([]models.Configuration, error)
GetConfigurationByUUID(uuid string) (*models.Configuration, error)
DeleteConfiguration(uuid string) error
@@ -159,16 +159,17 @@ func (r *UnifiedRepo) SaveConfiguration(cfg *models.Configuration) error {
// Offline: save to local SQLite and queue for sync
localCfg := &localdb.LocalConfiguration{
UUID: cfg.UUID,
Name: cfg.Name,
TotalPrice: cfg.TotalPrice,
CustomPrice: cfg.CustomPrice,
Notes: cfg.Notes,
IsTemplate: cfg.IsTemplate,
ServerCount: cfg.ServerCount,
CreatedAt: cfg.CreatedAt,
UpdatedAt: time.Now(),
SyncStatus: "pending",
UUID: cfg.UUID,
Name: cfg.Name,
TotalPrice: cfg.TotalPrice,
CustomPrice: cfg.CustomPrice,
Notes: cfg.Notes,
IsTemplate: cfg.IsTemplate,
ServerCount: cfg.ServerCount,
CreatedAt: cfg.CreatedAt,
UpdatedAt: time.Now(),
SyncStatus: "pending",
OriginalUsername: cfg.OwnerUsername,
}
// Convert items
@@ -196,10 +197,10 @@ func (r *UnifiedRepo) SaveConfiguration(cfg *models.Configuration) error {
}
// GetConfigurations returns all configurations for a user
func (r *UnifiedRepo) GetConfigurations(userID uint) ([]models.Configuration, error) {
func (r *UnifiedRepo) GetConfigurations(ownerUsername string) ([]models.Configuration, error) {
if r.isOnline {
repo := NewConfigurationRepository(r.mariaDB)
configs, _, err := repo.ListByUser(userID, 0, 1000)
configs, _, err := repo.ListByUser(ownerUsername, 0, 1000)
return configs, err
}
@@ -222,15 +223,16 @@ func (r *UnifiedRepo) GetConfigurations(userID uint) ([]models.Configuration, er
}
result[i] = models.Configuration{
UUID: lc.UUID,
Name: lc.Name,
Items: items,
TotalPrice: lc.TotalPrice,
CustomPrice: lc.CustomPrice,
Notes: lc.Notes,
IsTemplate: lc.IsTemplate,
ServerCount: lc.ServerCount,
CreatedAt: lc.CreatedAt,
UUID: lc.UUID,
OwnerUsername: lc.OriginalUsername,
Name: lc.Name,
Items: items,
TotalPrice: lc.TotalPrice,
CustomPrice: lc.CustomPrice,
Notes: lc.Notes,
IsTemplate: lc.IsTemplate,
ServerCount: lc.ServerCount,
CreatedAt: lc.CreatedAt,
}
}

View File

@@ -4,50 +4,67 @@ import (
"errors"
"time"
"github.com/google/uuid"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"github.com/google/uuid"
)
var (
ErrConfigNotFound = errors.New("configuration not found")
ErrConfigForbidden = errors.New("access to configuration forbidden")
ErrConfigNotFound = errors.New("configuration not found")
ErrConfigForbidden = errors.New("access to configuration forbidden")
)
// ConfigurationGetter is an interface for services that can retrieve configurations
// Used by handlers to work with both ConfigurationService and LocalConfigurationService
type ConfigurationGetter interface {
GetByUUID(uuid string, userID uint) (*models.Configuration, error)
GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error)
}
type ConfigurationService struct {
configRepo *repository.ConfigurationRepository
projectRepo *repository.ProjectRepository
componentRepo *repository.ComponentRepository
pricelistRepo *repository.PricelistRepository
quoteService *QuoteService
}
func NewConfigurationService(
configRepo *repository.ConfigurationRepository,
projectRepo *repository.ProjectRepository,
componentRepo *repository.ComponentRepository,
pricelistRepo *repository.PricelistRepository,
quoteService *QuoteService,
) *ConfigurationService {
return &ConfigurationService{
configRepo: configRepo,
projectRepo: projectRepo,
componentRepo: componentRepo,
pricelistRepo: pricelistRepo,
quoteService: quoteService,
}
}
type CreateConfigRequest struct {
Name string `json:"name"`
Items models.ConfigItems `json:"items"`
CustomPrice *float64 `json:"custom_price"`
Notes string `json:"notes"`
IsTemplate bool `json:"is_template"`
ServerCount int `json:"server_count"`
Name string `json:"name"`
Items models.ConfigItems `json:"items"`
ProjectUUID *string `json:"project_uuid,omitempty"`
CustomPrice *float64 `json:"custom_price"`
Notes string `json:"notes"`
IsTemplate bool `json:"is_template"`
ServerCount int `json:"server_count"`
PricelistID *uint `json:"pricelist_id,omitempty"`
}
func (s *ConfigurationService) Create(userID uint, req *CreateConfigRequest) (*models.Configuration, error) {
func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
if err != nil {
return nil, err
}
pricelistID, err := s.resolvePricelistID(req.PricelistID)
if err != nil {
return nil, err
}
total := req.Items.Total()
// If server count is greater than 1, multiply the total by server count
@@ -56,15 +73,17 @@ func (s *ConfigurationService) Create(userID uint, req *CreateConfigRequest) (*m
}
config := &models.Configuration{
UUID: uuid.New().String(),
UserID: userID,
Name: req.Name,
Items: req.Items,
TotalPrice: &total,
CustomPrice: req.CustomPrice,
Notes: req.Notes,
IsTemplate: req.IsTemplate,
ServerCount: req.ServerCount,
UUID: uuid.New().String(),
OwnerUsername: ownerUsername,
ProjectUUID: projectUUID,
Name: req.Name,
Items: req.Items,
TotalPrice: &total,
CustomPrice: req.CustomPrice,
Notes: req.Notes,
IsTemplate: req.IsTemplate,
ServerCount: req.ServerCount,
PricelistID: pricelistID,
}
if err := s.configRepo.Create(config); err != nil {
@@ -77,30 +96,39 @@ func (s *ConfigurationService) Create(userID uint, req *CreateConfigRequest) (*m
return config, nil
}
func (s *ConfigurationService) GetByUUID(uuid string, userID uint) (*models.Configuration, error) {
func (s *ConfigurationService) GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error) {
config, err := s.configRepo.GetByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
// Allow access if user owns config or it's a template
if config.UserID != userID && !config.IsTemplate {
if !s.isOwner(config, ownerUsername) && !config.IsTemplate {
return nil, ErrConfigForbidden
}
return config, nil
}
func (s *ConfigurationService) Update(uuid string, userID uint, req *CreateConfigRequest) (*models.Configuration, error) {
func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
config, err := s.configRepo.GetByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
if config.UserID != userID {
if !s.isOwner(config, ownerUsername) {
return nil, ErrConfigForbidden
}
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
if err != nil {
return nil, err
}
pricelistID, err := s.resolvePricelistID(req.PricelistID)
if err != nil {
return nil, err
}
total := req.Items.Total()
// If server count is greater than 1, multiply the total by server count
@@ -109,12 +137,14 @@ func (s *ConfigurationService) Update(uuid string, userID uint, req *CreateConfi
}
config.Name = req.Name
config.ProjectUUID = projectUUID
config.Items = req.Items
config.TotalPrice = &total
config.CustomPrice = req.CustomPrice
config.Notes = req.Notes
config.IsTemplate = req.IsTemplate
config.ServerCount = req.ServerCount
config.PricelistID = pricelistID
if err := s.configRepo.Update(config); err != nil {
return nil, err
@@ -123,26 +153,26 @@ func (s *ConfigurationService) Update(uuid string, userID uint, req *CreateConfi
return config, nil
}
func (s *ConfigurationService) Delete(uuid string, userID uint) error {
func (s *ConfigurationService) Delete(uuid string, ownerUsername string) error {
config, err := s.configRepo.GetByUUID(uuid)
if err != nil {
return ErrConfigNotFound
}
if config.UserID != userID {
if !s.isOwner(config, ownerUsername) {
return ErrConfigForbidden
}
return s.configRepo.Delete(config.ID)
}
func (s *ConfigurationService) Rename(uuid string, userID uint, newName string) (*models.Configuration, error) {
func (s *ConfigurationService) Rename(uuid string, ownerUsername string, newName string) (*models.Configuration, error) {
config, err := s.configRepo.GetByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
if config.UserID != userID {
if !s.isOwner(config, ownerUsername) {
return nil, ErrConfigForbidden
}
@@ -155,11 +185,22 @@ func (s *ConfigurationService) Rename(uuid string, userID uint, newName string)
return config, nil
}
func (s *ConfigurationService) Clone(configUUID string, userID uint, newName string) (*models.Configuration, error) {
original, err := s.GetByUUID(configUUID, userID)
func (s *ConfigurationService) Clone(configUUID string, ownerUsername string, newName string) (*models.Configuration, error) {
return s.CloneToProject(configUUID, ownerUsername, newName, nil)
}
func (s *ConfigurationService) CloneToProject(configUUID string, ownerUsername string, newName string, projectUUID *string) (*models.Configuration, error) {
original, err := s.GetByUUID(configUUID, ownerUsername)
if err != nil {
return nil, err
}
resolvedProjectUUID := original.ProjectUUID
if projectUUID != nil {
resolvedProjectUUID, err = s.resolveProjectUUID(ownerUsername, projectUUID)
if err != nil {
return nil, err
}
}
// Create copy with new UUID and name
total := original.Items.Total()
@@ -170,15 +211,17 @@ func (s *ConfigurationService) Clone(configUUID string, userID uint, newName str
}
clone := &models.Configuration{
UUID: uuid.New().String(),
UserID: userID,
Name: newName,
Items: original.Items,
TotalPrice: &total,
CustomPrice: original.CustomPrice,
Notes: original.Notes,
IsTemplate: false, // Clone is never a template
ServerCount: original.ServerCount,
UUID: uuid.New().String(),
OwnerUsername: ownerUsername,
ProjectUUID: resolvedProjectUUID,
Name: newName,
Items: original.Items,
TotalPrice: &total,
CustomPrice: original.CustomPrice,
Notes: original.Notes,
IsTemplate: false, // Clone is never a template
ServerCount: original.ServerCount,
PricelistID: original.PricelistID,
}
if err := s.configRepo.Create(clone); err != nil {
@@ -188,7 +231,7 @@ func (s *ConfigurationService) Clone(configUUID string, userID uint, newName str
return clone, nil
}
func (s *ConfigurationService) ListByUser(userID uint, page, perPage int) ([]models.Configuration, int64, error) {
func (s *ConfigurationService) ListByUser(ownerUsername string, page, perPage int) ([]models.Configuration, int64, error) {
if page < 1 {
page = 1
}
@@ -197,7 +240,7 @@ func (s *ConfigurationService) ListByUser(userID uint, page, perPage int) ([]mod
}
offset := (page - 1) * perPage
return s.configRepo.ListByUser(userID, offset, perPage)
return s.configRepo.ListByUser(ownerUsername, offset, perPage)
}
// ListAll returns all configurations without user filter (for use when auth is disabled)
@@ -229,18 +272,29 @@ func (s *ConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigReques
return nil, ErrConfigNotFound
}
projectUUID, err := s.resolveProjectUUID(config.OwnerUsername, req.ProjectUUID)
if err != nil {
return nil, err
}
pricelistID, err := s.resolvePricelistID(req.PricelistID)
if err != nil {
return nil, err
}
total := req.Items.Total()
if req.ServerCount > 1 {
total *= float64(req.ServerCount)
}
config.Name = req.Name
config.ProjectUUID = projectUUID
config.Items = req.Items
config.TotalPrice = &total
config.CustomPrice = req.CustomPrice
config.Notes = req.Notes
config.IsTemplate = req.IsTemplate
config.ServerCount = req.ServerCount
config.PricelistID = pricelistID
if err := s.configRepo.Update(config); err != nil {
return nil, err
@@ -274,11 +328,22 @@ func (s *ConfigurationService) RenameNoAuth(uuid string, newName string) (*model
}
// CloneNoAuth clones configuration without ownership check
func (s *ConfigurationService) CloneNoAuth(configUUID string, newName string, userID uint) (*models.Configuration, error) {
func (s *ConfigurationService) CloneNoAuth(configUUID string, newName string, ownerUsername string) (*models.Configuration, error) {
return s.CloneNoAuthToProject(configUUID, newName, ownerUsername, nil)
}
func (s *ConfigurationService) CloneNoAuthToProject(configUUID string, newName string, ownerUsername string, projectUUID *string) (*models.Configuration, error) {
original, err := s.configRepo.GetByUUID(configUUID)
if err != nil {
return nil, ErrConfigNotFound
}
resolvedProjectUUID := original.ProjectUUID
if projectUUID != nil {
resolvedProjectUUID, err = s.resolveProjectUUID(ownerUsername, projectUUID)
if err != nil {
return nil, err
}
}
total := original.Items.Total()
if original.ServerCount > 1 {
@@ -286,15 +351,17 @@ func (s *ConfigurationService) CloneNoAuth(configUUID string, newName string, us
}
clone := &models.Configuration{
UUID: uuid.New().String(),
UserID: userID, // Use provided user ID
Name: newName,
Items: original.Items,
TotalPrice: &total,
CustomPrice: original.CustomPrice,
Notes: original.Notes,
IsTemplate: false,
ServerCount: original.ServerCount,
UUID: uuid.New().String(),
OwnerUsername: ownerUsername,
ProjectUUID: resolvedProjectUUID,
Name: newName,
Items: original.Items,
TotalPrice: &total,
CustomPrice: original.CustomPrice,
Notes: original.Notes,
IsTemplate: false,
ServerCount: original.ServerCount,
PricelistID: original.PricelistID,
}
if err := s.configRepo.Create(clone); err != nil {
@@ -304,6 +371,43 @@ func (s *ConfigurationService) CloneNoAuth(configUUID string, newName string, us
return clone, nil
}
func (s *ConfigurationService) resolveProjectUUID(ownerUsername string, projectUUID *string) (*string, error) {
_ = ownerUsername
if s.projectRepo == nil {
return projectUUID, nil
}
if projectUUID == nil || *projectUUID == "" {
return nil, nil
}
project, err := s.projectRepo.GetByUUID(*projectUUID)
if err != nil {
return nil, ErrProjectNotFound
}
if !project.IsActive {
return nil, errors.New("project is archived")
}
return &project.UUID, nil
}
func (s *ConfigurationService) resolvePricelistID(pricelistID *uint) (*uint, error) {
if s.pricelistRepo == nil {
return pricelistID, nil
}
if pricelistID != nil && *pricelistID > 0 {
if _, err := s.pricelistRepo.GetByID(*pricelistID); err != nil {
return nil, err
}
return pricelistID, nil
}
latest, err := s.pricelistRepo.GetLatestActive()
if err != nil {
return nil, nil
}
return &latest.ID, nil
}
// RefreshPricesNoAuth refreshes prices without ownership check
func (s *ConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) {
config, err := s.configRepo.GetByUUID(uuid)
@@ -311,8 +415,30 @@ func (s *ConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configu
return nil, ErrConfigNotFound
}
var latestPricelistID *uint
if s.pricelistRepo != nil {
if pl, err := s.pricelistRepo.GetLatestActive(); err == nil {
latestPricelistID = &pl.ID
}
}
updatedItems := make(models.ConfigItems, len(config.Items))
for i, item := range config.Items {
if latestPricelistID != nil {
if price, err := s.pricelistRepo.GetPriceForLot(*latestPricelistID, item.LotName); err == nil && price > 0 {
updatedItems[i] = models.ConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: price,
}
continue
}
}
if s.componentRepo == nil {
updatedItems[i] = item
continue
}
metadata, err := s.componentRepo.GetByLotName(item.LotName)
if err != nil || metadata.CurrentPrice == nil {
updatedItems[i] = item
@@ -333,6 +459,9 @@ func (s *ConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configu
}
config.TotalPrice = &total
if latestPricelistID != nil {
config.PricelistID = latestPricelistID
}
now := time.Now()
config.PriceUpdatedAt = &now
@@ -356,20 +485,42 @@ func (s *ConfigurationService) ListTemplates(page, perPage int) ([]models.Config
}
// RefreshPrices updates all component prices in the configuration with current prices
func (s *ConfigurationService) RefreshPrices(uuid string, userID uint) (*models.Configuration, error) {
func (s *ConfigurationService) RefreshPrices(uuid string, ownerUsername string) (*models.Configuration, error) {
config, err := s.configRepo.GetByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
if config.UserID != userID {
if !s.isOwner(config, ownerUsername) {
return nil, ErrConfigForbidden
}
var latestPricelistID *uint
if s.pricelistRepo != nil {
if pl, err := s.pricelistRepo.GetLatestActive(); err == nil {
latestPricelistID = &pl.ID
}
}
// Update prices for all items
updatedItems := make(models.ConfigItems, len(config.Items))
for i, item := range config.Items {
if latestPricelistID != nil {
if price, err := s.pricelistRepo.GetPriceForLot(*latestPricelistID, item.LotName); err == nil && price > 0 {
updatedItems[i] = models.ConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: price,
}
continue
}
}
// Get current component price
if s.componentRepo == nil {
updatedItems[i] = item
continue
}
metadata, err := s.componentRepo.GetByLotName(item.LotName)
if err != nil || metadata.CurrentPrice == nil {
// Keep original item if component not found or no price available
@@ -395,6 +546,9 @@ func (s *ConfigurationService) RefreshPrices(uuid string, userID uint) (*models.
}
config.TotalPrice = &total
if latestPricelistID != nil {
config.PricelistID = latestPricelistID
}
// Set price update timestamp
now := time.Now()
@@ -407,6 +561,19 @@ func (s *ConfigurationService) RefreshPrices(uuid string, userID uint) (*models.
return config, nil
}
func (s *ConfigurationService) isOwner(config *models.Configuration, ownerUsername string) bool {
if config == nil || ownerUsername == "" {
return false
}
if config.OwnerUsername != "" {
return config.OwnerUsername == ownerUsername
}
if config.User != nil {
return config.User.Username == ownerUsername
}
return false
}
// // Export configuration as JSON
// type ConfigExport struct {
// Name string `json:"name"`

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,399 @@
package services
import (
"encoding/json"
"errors"
"fmt"
"path/filepath"
"strings"
"sync"
"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 TestSaveCreatesNewVersionAndUpdatesCurrentPointer(t *testing.T) {
service, local := newLocalConfigServiceForTest(t)
created, err := service.Create("tester", &CreateConfigRequest{
Name: "v1",
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}},
ServerCount: 1,
})
if err != nil {
t.Fatalf("create config: %v", err)
}
if _, err := service.RenameNoAuth(created.UUID, "v2"); err != nil {
t.Fatalf("rename config: %v", err)
}
versions := loadVersions(t, local, created.UUID)
if len(versions) != 2 {
t.Fatalf("expected 2 versions, got %d", len(versions))
}
if versions[0].VersionNo != 1 || versions[1].VersionNo != 2 {
t.Fatalf("expected version_no [1,2], got [%d,%d]", versions[0].VersionNo, versions[1].VersionNo)
}
cfg, err := local.GetConfigurationByUUID(created.UUID)
if err != nil {
t.Fatalf("load local config: %v", err)
}
if cfg.CurrentVersionID == nil || *cfg.CurrentVersionID != versions[1].ID {
t.Fatalf("current_version_id should point to v2")
}
}
func TestRollbackCreatesNewVersionWithTargetData(t *testing.T) {
service, local := newLocalConfigServiceForTest(t)
created, err := service.Create("tester", &CreateConfigRequest{
Name: "base",
Items: models.ConfigItems{{LotName: "RAM_A", Quantity: 2, UnitPrice: 100}},
ServerCount: 1,
})
if err != nil {
t.Fatalf("create config: %v", err)
}
if _, err := service.RenameNoAuth(created.UUID, "changed"); err != nil {
t.Fatalf("rename config: %v", err)
}
if _, err := service.RollbackToVersionWithNote(created.UUID, 1, "tester", "test rollback"); err != nil {
t.Fatalf("rollback to v1: %v", err)
}
versions := loadVersions(t, local, created.UUID)
if len(versions) != 3 {
t.Fatalf("expected 3 versions, got %d", len(versions))
}
if versions[2].VersionNo != 3 {
t.Fatalf("expected v3 as rollback version, got v%d", versions[2].VersionNo)
}
if versions[2].Data != versions[0].Data {
t.Fatalf("expected rollback snapshot data equal to v1 data")
}
}
func TestAppendOnlyInvariantOldRowsUnchanged(t *testing.T) {
service, local := newLocalConfigServiceForTest(t)
created, err := service.Create("tester", &CreateConfigRequest{
Name: "initial",
Items: models.ConfigItems{{LotName: "SSD_A", Quantity: 1, UnitPrice: 300}},
ServerCount: 1,
})
if err != nil {
t.Fatalf("create config: %v", err)
}
versionsBefore := loadVersions(t, local, created.UUID)
if len(versionsBefore) != 1 {
t.Fatalf("expected exactly one version after create")
}
v1Before := versionsBefore[0]
if _, err := service.RenameNoAuth(created.UUID, "after-rename"); err != nil {
t.Fatalf("rename config: %v", err)
}
if _, err := service.RollbackToVersion(created.UUID, 1, "tester"); err != nil {
t.Fatalf("rollback: %v", err)
}
versionsAfter := loadVersions(t, local, created.UUID)
if len(versionsAfter) != 3 {
t.Fatalf("expected 3 versions, got %d", len(versionsAfter))
}
v1After := versionsAfter[0]
if v1After.ID != v1Before.ID {
t.Fatalf("v1 id changed: before=%s after=%s", v1Before.ID, v1After.ID)
}
if v1After.Data != v1Before.Data {
t.Fatalf("v1 data changed")
}
if !v1After.CreatedAt.Equal(v1Before.CreatedAt) {
t.Fatalf("v1 created_at changed")
}
}
func TestConcurrentSaveNoDuplicateVersionNumbers(t *testing.T) {
service, local := newLocalConfigServiceForTest(t)
created, err := service.Create("tester", &CreateConfigRequest{
Name: "base",
Items: models.ConfigItems{{LotName: "NIC_A", Quantity: 1, UnitPrice: 150}},
ServerCount: 1,
})
if err != nil {
t.Fatalf("create config: %v", err)
}
const workers = 8
start := make(chan struct{})
errCh := make(chan error, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
i := i
wg.Add(1)
go func() {
defer wg.Done()
<-start
if err := renameWithRetry(service, created.UUID, fmt.Sprintf("name-%d", i)); err != nil {
errCh <- err
}
}()
}
close(start)
wg.Wait()
close(errCh)
for err := range errCh {
if err != nil {
t.Fatalf("concurrent save failed: %v", err)
}
}
type counts struct {
Total int64
DistinctCount int64
Max int
}
var c counts
if err := local.DB().Raw(`
SELECT
COUNT(*) as total,
COUNT(DISTINCT version_no) as distinct_count,
COALESCE(MAX(version_no), 0) as max
FROM local_configuration_versions
WHERE configuration_uuid = ?`, created.UUID).Scan(&c).Error; err != nil {
t.Fatalf("query version counts: %v", err)
}
if c.Total != c.DistinctCount {
t.Fatalf("duplicate version numbers detected: total=%d distinct=%d", c.Total, c.DistinctCount)
}
expected := int64(workers + 1) // initial create version + each successful save
if c.Total != expected || c.Max != int(expected) {
t.Fatalf("expected total=max=%d, got total=%d max=%d", expected, c.Total, c.Max)
}
}
func TestUpdateNoAuthKeepsProjectWhenProjectUUIDOmitted(t *testing.T) {
service, local := newLocalConfigServiceForTest(t)
project := &localdb.LocalProject{
UUID: "project-keep",
OwnerUsername: "tester",
Name: "Keep Project",
IsActive: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
SyncStatus: "synced",
}
if err := local.SaveProject(project); err != nil {
t.Fatalf("save project: %v", err)
}
created, err := service.Create("tester", &CreateConfigRequest{
Name: "cfg",
ProjectUUID: &project.UUID,
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 100}},
ServerCount: 1,
})
if err != nil {
t.Fatalf("create config: %v", err)
}
if created.ProjectUUID == nil || *created.ProjectUUID != project.UUID {
t.Fatalf("expected created config project_uuid=%s", project.UUID)
}
updated, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
Name: "cfg-updated",
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 100}},
ServerCount: 1,
})
if err != nil {
t.Fatalf("update config without project_uuid: %v", err)
}
if updated.ProjectUUID == nil || *updated.ProjectUUID != project.UUID {
t.Fatalf("expected project_uuid to stay %s after update, got %+v", project.UUID, updated.ProjectUUID)
}
}
func newLocalConfigServiceForTest(t *testing.T) (*LocalConfigurationService, *localdb.LocalDB) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "local.db")
local, err := localdb.New(dbPath)
if err != nil {
t.Fatalf("init local db: %v", err)
}
t.Cleanup(func() {
_ = local.Close()
})
return NewLocalConfigurationService(
local,
syncsvc.NewService(nil, local),
&QuoteService{},
func() bool { return false },
), local
}
func loadVersions(t *testing.T, local *localdb.LocalDB, configurationUUID string) []localdb.LocalConfigurationVersion {
t.Helper()
var versions []localdb.LocalConfigurationVersion
if err := local.DB().
Where("configuration_uuid = ?", configurationUUID).
Order("version_no ASC").
Find(&versions).Error; err != nil {
t.Fatalf("load versions: %v", err)
}
return versions
}
func renameWithRetry(service *LocalConfigurationService, uuid string, name string) error {
var lastErr error
for i := 0; i < 6; i++ {
_, err := service.RenameNoAuth(uuid, name)
if err == nil {
return nil
}
lastErr = err
if errors.Is(err, ErrVersionConflict) || strings.Contains(err.Error(), "database is locked") {
time.Sleep(10 * time.Millisecond)
continue
}
return err
}
return fmt.Errorf("rename retries exhausted: %w", lastErr)
}
func TestRollbackVersionSnapshotJSONMatchesV1(t *testing.T) {
service, local := newLocalConfigServiceForTest(t)
created, err := service.Create("tester", &CreateConfigRequest{
Name: "initial",
Items: models.ConfigItems{{LotName: "GPU_A", Quantity: 1, UnitPrice: 2000}},
ServerCount: 1,
})
if err != nil {
t.Fatalf("create config: %v", err)
}
if _, err := service.RenameNoAuth(created.UUID, "second"); err != nil {
t.Fatalf("rename: %v", err)
}
if _, err := service.RollbackToVersion(created.UUID, 1, "tester"); err != nil {
t.Fatalf("rollback: %v", err)
}
versions := loadVersions(t, local, created.UUID)
if len(versions) != 3 {
t.Fatalf("expected 3 versions")
}
var v1 map[string]any
var v3 map[string]any
if err := json.Unmarshal([]byte(versions[0].Data), &v1); err != nil {
t.Fatalf("unmarshal v1: %v", err)
}
if err := json.Unmarshal([]byte(versions[2].Data), &v3); err != nil {
t.Fatalf("unmarshal v3: %v", err)
}
if fmt.Sprintf("%v", v1["name"]) != fmt.Sprintf("%v", v3["name"]) {
t.Fatalf("rollback snapshot differs from v1 snapshot by name")
}
}
func TestDeleteMarksInactiveAndCreatesVersion(t *testing.T) {
service, local := newLocalConfigServiceForTest(t)
created, err := service.Create("tester", &CreateConfigRequest{
Name: "to-archive",
Items: models.ConfigItems{{LotName: "CPU_Z", Quantity: 1, UnitPrice: 500}},
ServerCount: 1,
})
if err != nil {
t.Fatalf("create config: %v", err)
}
if err := service.DeleteNoAuth(created.UUID); err != nil {
t.Fatalf("delete no auth: %v", err)
}
cfg, err := local.GetConfigurationByUUID(created.UUID)
if err != nil {
t.Fatalf("load archived config: %v", err)
}
if cfg.IsActive {
t.Fatalf("expected config to be inactive after delete")
}
versions := loadVersions(t, local, created.UUID)
if len(versions) != 2 {
t.Fatalf("expected 2 versions after archive, got %d", len(versions))
}
if versions[1].VersionNo != 2 {
t.Fatalf("expected archive to create version 2, got %d", versions[1].VersionNo)
}
list, total, err := service.ListAll(1, 20)
if err != nil {
t.Fatalf("list all: %v", err)
}
if total != int64(len(list)) {
t.Fatalf("unexpected total/list mismatch")
}
if len(list) != 0 {
t.Fatalf("expected archived config to be hidden from list")
}
}
func TestReactivateRestoresArchivedConfigurationAndCreatesVersion(t *testing.T) {
service, local := newLocalConfigServiceForTest(t)
created, err := service.Create("tester", &CreateConfigRequest{
Name: "to-reactivate",
Items: models.ConfigItems{{LotName: "CPU_R", Quantity: 1, UnitPrice: 700}},
ServerCount: 1,
})
if err != nil {
t.Fatalf("create config: %v", err)
}
if err := service.DeleteNoAuth(created.UUID); err != nil {
t.Fatalf("archive config: %v", err)
}
if _, err := service.ReactivateNoAuth(created.UUID); err != nil {
t.Fatalf("reactivate config: %v", err)
}
cfg, err := local.GetConfigurationByUUID(created.UUID)
if err != nil {
t.Fatalf("load reactivated config: %v", err)
}
if !cfg.IsActive {
t.Fatalf("expected config to be active after reactivation")
}
versions := loadVersions(t, local, created.UUID)
if len(versions) != 3 {
t.Fatalf("expected 3 versions after reactivation, got %d", len(versions))
}
if versions[2].VersionNo != 3 {
t.Fatalf("expected reactivation version 3, got %d", versions[2].VersionNo)
}
list, _, err := service.ListAll(1, 20)
if err != nil {
t.Fatalf("list all after reactivation: %v", err)
}
if len(list) != 1 {
t.Fatalf("expected reactivated config to be visible in list")
}
}

View File

@@ -1,76 +1,173 @@
package pricelist
import (
"errors"
"fmt"
"log/slog"
"strings"
"time"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
"gorm.io/gorm"
)
type Service struct {
repo *repository.PricelistRepository
componentRepo *repository.ComponentRepository
pricingSvc *pricing.Service
db *gorm.DB
}
func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo *repository.ComponentRepository) *Service {
type CreateProgress struct {
Current int
Total int
Status string
Message string
Updated int
Errors int
LotName string
}
type CreateItemInput struct {
LotName string
Price float64
}
func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo *repository.ComponentRepository, pricingSvc *pricing.Service) *Service {
return &Service{
repo: repo,
componentRepo: componentRepo,
pricingSvc: pricingSvc,
db: db,
}
}
// CreateFromCurrentPrices creates a new pricelist by taking a snapshot of current prices
func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist, error) {
return s.CreateFromCurrentPricesForSource(createdBy, string(models.PricelistSourceEstimate))
}
// CreateFromCurrentPricesForSource creates a new pricelist snapshot for one source.
func (s *Service) CreateFromCurrentPricesForSource(createdBy, source string) (*models.Pricelist, error) {
return s.CreateForSourceWithProgress(createdBy, source, nil, nil)
}
// CreateFromCurrentPricesWithProgress creates a pricelist and reports coarse-grained progress.
func (s *Service) CreateFromCurrentPricesWithProgress(createdBy, source string, onProgress func(CreateProgress)) (*models.Pricelist, error) {
return s.CreateForSourceWithProgress(createdBy, source, nil, onProgress)
}
// CreateForSourceWithProgress creates a source pricelist from current estimate snapshot or explicit item list.
func (s *Service) CreateForSourceWithProgress(createdBy, source string, sourceItems []CreateItemInput, onProgress func(CreateProgress)) (*models.Pricelist, error) {
if s.repo == nil || s.db == nil {
return nil, fmt.Errorf("offline mode: cannot create pricelists")
}
source = string(models.NormalizePricelistSource(source))
version, err := s.repo.GenerateVersion()
if err != nil {
return nil, fmt.Errorf("generating version: %w", err)
}
expiresAt := time.Now().AddDate(1, 0, 0) // +1 year
pricelist := &models.Pricelist{
Version: version,
CreatedBy: createdBy,
IsActive: true,
ExpiresAt: &expiresAt,
}
if err := s.repo.Create(pricelist); err != nil {
return nil, fmt.Errorf("creating pricelist: %w", err)
}
// Get all components with prices from qt_lot_metadata
var metadata []models.LotMetadata
if err := s.db.Where("current_price IS NOT NULL AND current_price > 0").Find(&metadata).Error; err != nil {
return nil, fmt.Errorf("getting lot metadata: %w", err)
}
// Create pricelist items with all price settings
items := make([]models.PricelistItem, 0, len(metadata))
for _, m := range metadata {
if m.CurrentPrice == nil || *m.CurrentPrice <= 0 {
continue
report := func(p CreateProgress) {
if onProgress != nil {
onProgress(p)
}
items = append(items, models.PricelistItem{
PricelistID: pricelist.ID,
LotName: m.LotName,
Price: *m.CurrentPrice,
PriceMethod: string(m.PriceMethod),
PricePeriodDays: m.PricePeriodDays,
PriceCoefficient: m.PriceCoefficient,
ManualPrice: m.ManualPrice,
MetaPrices: m.MetaPrices,
}
report(CreateProgress{Current: 0, Total: 100, Status: "starting", Message: "Подготовка"})
updated, errs := 0, 0
if source == string(models.PricelistSourceEstimate) && s.pricingSvc != nil {
report(CreateProgress{Current: 1, Total: 100, Status: "recalculating", Message: "Обновление цен компонентов"})
updated, errs = s.pricingSvc.RecalculateAllPricesWithProgress(func(p pricing.RecalculateProgress) {
if p.Total <= 0 {
return
}
phaseCurrent := 1 + int(float64(p.Current)/float64(p.Total)*90.0)
if phaseCurrent > 91 {
phaseCurrent = 91
}
report(CreateProgress{
Current: phaseCurrent,
Total: 100,
Status: "recalculating",
Message: "Обновление цен компонентов",
Updated: p.Updated,
Errors: p.Errors,
LotName: p.LotName,
})
})
}
report(CreateProgress{Current: 92, Total: 100, Status: "recalculated", Message: "Цены обновлены", Updated: updated, Errors: errs})
report(CreateProgress{Current: 95, Total: 100, Status: "snapshot", Message: "Создание снимка прайслиста"})
expiresAt := time.Now().AddDate(1, 0, 0) // +1 year
const maxCreateAttempts = 5
var pricelist *models.Pricelist
for attempt := 1; attempt <= maxCreateAttempts; attempt++ {
version, err := s.repo.GenerateVersionBySource(source)
if err != nil {
return nil, fmt.Errorf("generating version: %w", err)
}
pricelist = &models.Pricelist{
Source: source,
Version: version,
CreatedBy: createdBy,
IsActive: true,
ExpiresAt: &expiresAt,
}
if err := s.repo.Create(pricelist); err != nil {
if isVersionConflictError(err) && attempt < maxCreateAttempts {
slog.Warn("pricelist version conflict, retrying",
"attempt", attempt,
"version", version,
"error", err,
)
time.Sleep(time.Duration(attempt*25) * time.Millisecond)
continue
}
return nil, fmt.Errorf("creating pricelist: %w", err)
}
break
}
items := make([]models.PricelistItem, 0)
if len(sourceItems) > 0 {
items = make([]models.PricelistItem, 0, len(sourceItems))
for _, srcItem := range sourceItems {
if strings.TrimSpace(srcItem.LotName) == "" || srcItem.Price <= 0 {
continue
}
items = append(items, models.PricelistItem{
PricelistID: pricelist.ID,
LotName: strings.TrimSpace(srcItem.LotName),
Price: srcItem.Price,
})
}
} else {
// Default snapshot source for estimate and backward compatibility.
var metadata []models.LotMetadata
if err := s.db.Where("current_price IS NOT NULL AND current_price > 0").Find(&metadata).Error; err != nil {
return nil, fmt.Errorf("getting lot metadata: %w", err)
}
// Create pricelist items with all price settings
items = make([]models.PricelistItem, 0, len(metadata))
for _, m := range metadata {
if m.CurrentPrice == nil || *m.CurrentPrice <= 0 {
continue
}
items = append(items, models.PricelistItem{
PricelistID: pricelist.ID,
LotName: m.LotName,
Price: *m.CurrentPrice,
PriceMethod: string(m.PriceMethod),
PricePeriodDays: m.PricePeriodDays,
PriceCoefficient: m.PriceCoefficient,
ManualPrice: m.ManualPrice,
MetaPrices: m.MetaPrices,
})
}
}
if err := s.repo.CreateItems(items); err != nil {
// Clean up the pricelist if items creation fails
@@ -86,12 +183,27 @@ func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist,
"items", len(items),
"created_by", createdBy,
)
report(CreateProgress{Current: 100, Total: 100, Status: "completed", Message: "Прайслист создан", Updated: updated, Errors: errs})
return pricelist, nil
}
func isVersionConflictError(err error) bool {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return true
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "duplicate entry") &&
(strings.Contains(msg, "idx_qt_pricelists_source_version") || strings.Contains(msg, "idx_qt_pricelists_version"))
}
// List returns pricelists with pagination
func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, error) {
return s.ListBySource(page, perPage, "")
}
// ListBySource returns pricelists with optional source filter.
func (s *Service) ListBySource(page, perPage int, source string) ([]models.PricelistSummary, int64, error) {
// If no database connection (offline mode), return empty list
if s.repo == nil {
return []models.PricelistSummary{}, 0, nil
@@ -104,7 +216,27 @@ func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, err
perPage = 20
}
offset := (page - 1) * perPage
return s.repo.List(offset, perPage)
return s.repo.ListBySource(source, offset, perPage)
}
// ListActive returns active pricelists with pagination.
func (s *Service) ListActive(page, perPage int) ([]models.PricelistSummary, int64, error) {
return s.ListActiveBySource(page, perPage, "")
}
// ListActiveBySource returns active pricelists with optional source filter.
func (s *Service) ListActiveBySource(page, perPage int, source string) ([]models.PricelistSummary, int64, error) {
if s.repo == nil {
return []models.PricelistSummary{}, 0, nil
}
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 20
}
offset := (page - 1) * perPage
return s.repo.ListActiveBySource(source, offset, perPage)
}
// GetByID returns a pricelist by ID
@@ -138,6 +270,22 @@ func (s *Service) Delete(id uint) error {
return s.repo.Delete(id)
}
// SetActive toggles active state for a pricelist.
func (s *Service) SetActive(id uint, isActive bool) error {
if s.repo == nil {
return fmt.Errorf("offline mode: cannot update pricelists")
}
return s.repo.SetActive(id, isActive)
}
// GetPriceForLot returns price by pricelist/lot.
func (s *Service) GetPriceForLot(pricelistID uint, lotName string) (float64, error) {
if s.repo == nil {
return 0, fmt.Errorf("offline mode: pricelist service not available")
}
return s.repo.GetPriceForLot(pricelistID, lotName)
}
// CanWrite returns true if the user can create pricelists
func (s *Service) CanWrite() bool {
if s.repo == nil {
@@ -156,10 +304,15 @@ func (s *Service) CanWriteDebug() (bool, string) {
// GetLatestActive returns the most recent active pricelist
func (s *Service) GetLatestActive() (*models.Pricelist, error) {
return s.GetLatestActiveBySource(string(models.PricelistSourceEstimate))
}
// GetLatestActiveBySource returns the latest active pricelist for a source.
func (s *Service) GetLatestActiveBySource(source string) (*models.Pricelist, error) {
if s.repo == nil {
return nil, fmt.Errorf("offline mode: pricelist service not available")
}
return s.repo.GetLatestActive()
return s.repo.GetLatestActiveBySource(source)
}
// CleanupExpired deletes expired and unused pricelists

View File

@@ -1,17 +1,28 @@
package pricing
import (
"strings"
"time"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"gorm.io/gorm"
)
type Service struct {
componentRepo *repository.ComponentRepository
priceRepo *repository.PriceRepository
config config.PricingConfig
db *gorm.DB
}
type RecalculateProgress struct {
Current int
Total int
LotName string
Updated int
Errors int
}
func NewService(
@@ -19,10 +30,16 @@ func NewService(
priceRepo *repository.PriceRepository,
cfg config.PricingConfig,
) *Service {
var db *gorm.DB
if componentRepo != nil {
db = componentRepo.DB()
}
return &Service{
componentRepo: componentRepo,
priceRepo: priceRepo,
config: cfg,
db: db,
}
}
@@ -179,27 +196,183 @@ type PriceStats struct {
// RecalculateAllPrices recalculates prices for all components
func (s *Service) RecalculateAllPrices() (updated int, errors int) {
// Get all components
filter := repository.ComponentFilter{}
offset := 0
limit := 100
return s.RecalculateAllPricesWithProgress(nil)
}
for {
components, _, err := s.componentRepo.List(filter, offset, limit)
if err != nil || len(components) == 0 {
break
}
// RecalculateAllPricesWithProgress recalculates prices and reports progress.
func (s *Service) RecalculateAllPricesWithProgress(onProgress func(RecalculateProgress)) (updated int, errors int) {
if s.db == nil {
return 0, 0
}
for _, comp := range components {
if err := s.UpdateComponentPrice(comp.LotName); err != nil {
errors++
} else {
updated++
// Logic mirrors "Обновить цены" in admin pricing.
var components []models.LotMetadata
if err := s.db.Find(&components).Error; err != nil {
return 0, len(components)
}
total := len(components)
var allLotNames []string
_ = s.db.Model(&models.LotMetadata{}).Pluck("lot_name", &allLotNames).Error
type lotDate struct {
Lot string
Date time.Time
}
var latestDates []lotDate
_ = s.db.Raw(`SELECT lot, MAX(date) as date FROM lot_log GROUP BY lot`).Scan(&latestDates).Error
lotLatestDate := make(map[string]time.Time, len(latestDates))
for _, ld := range latestDates {
lotLatestDate[ld.Lot] = ld.Date
}
var skipped, manual, unchanged int
now := time.Now()
current := 0
for _, comp := range components {
current++
reportProgress := func() {
if onProgress != nil && (current%10 == 0 || current == total) {
onProgress(RecalculateProgress{
Current: current,
Total: total,
LotName: comp.LotName,
Updated: updated,
Errors: errors,
})
}
}
offset += limit
if comp.ManualPrice != nil && *comp.ManualPrice > 0 {
manual++
reportProgress()
continue
}
method := comp.PriceMethod
if method == "" {
method = models.PriceMethodMedian
}
var sourceLots []string
if comp.MetaPrices != "" {
sourceLots = expandMetaPricesWithCache(comp.MetaPrices, comp.LotName, allLotNames)
} else {
sourceLots = []string{comp.LotName}
}
if len(sourceLots) == 0 {
skipped++
reportProgress()
continue
}
if comp.PriceUpdatedAt != nil {
hasNewData := false
for _, lot := range sourceLots {
if latestDate, ok := lotLatestDate[lot]; ok && latestDate.After(*comp.PriceUpdatedAt) {
hasNewData = true
break
}
}
if !hasNewData {
unchanged++
reportProgress()
continue
}
}
var prices []float64
if comp.PricePeriodDays > 0 {
_ = s.db.Raw(
`SELECT price FROM lot_log WHERE lot IN ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
sourceLots, comp.PricePeriodDays,
).Pluck("price", &prices).Error
} else {
_ = s.db.Raw(
`SELECT price FROM lot_log WHERE lot IN ? ORDER BY price`,
sourceLots,
).Pluck("price", &prices).Error
}
if len(prices) == 0 && comp.PricePeriodDays > 0 {
_ = s.db.Raw(`SELECT price FROM lot_log WHERE lot IN ? ORDER BY price`, sourceLots).Pluck("price", &prices).Error
}
if len(prices) == 0 {
skipped++
reportProgress()
continue
}
var basePrice float64
switch method {
case models.PriceMethodAverage:
basePrice = CalculateAverage(prices)
default:
basePrice = CalculateMedian(prices)
}
if basePrice <= 0 {
skipped++
reportProgress()
continue
}
finalPrice := basePrice
if comp.PriceCoefficient != 0 {
finalPrice = finalPrice * (1 + comp.PriceCoefficient/100)
}
if err := s.db.Model(&models.LotMetadata{}).
Where("lot_name = ?", comp.LotName).
Updates(map[string]interface{}{
"current_price": finalPrice,
"price_updated_at": now,
}).Error; err != nil {
errors++
} else {
updated++
}
reportProgress()
}
if onProgress != nil && total == 0 {
onProgress(RecalculateProgress{
Current: 0,
Total: 0,
LotName: "",
Updated: updated,
Errors: errors,
})
}
return updated, errors
}
func expandMetaPricesWithCache(metaPrices, excludeLot string, allLotNames []string) []string {
sources := strings.Split(metaPrices, ",")
var result []string
seen := make(map[string]bool)
for _, source := range sources {
source = strings.TrimSpace(source)
if source == "" || source == excludeLot {
continue
}
if strings.HasSuffix(source, "*") {
prefix := strings.TrimSuffix(source, "*")
for _, lot := range allLotNames {
if strings.HasPrefix(lot, prefix) && lot != excludeLot && !seen[lot] {
result = append(result, lot)
seen[lot] = true
}
}
} else if !seen[source] {
result = append(result, source)
seen[source] = true
}
}
return result
}

View File

@@ -0,0 +1,319 @@
package services
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
"time"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
"github.com/google/uuid"
"gorm.io/gorm"
)
var (
ErrProjectNotFound = errors.New("project not found")
ErrProjectForbidden = errors.New("access to project forbidden")
)
type ProjectService struct {
localDB *localdb.LocalDB
}
func NewProjectService(localDB *localdb.LocalDB) *ProjectService {
return &ProjectService{localDB: localDB}
}
type CreateProjectRequest struct {
Name string `json:"name"`
TrackerURL string `json:"tracker_url"`
}
type UpdateProjectRequest struct {
Name string `json:"name"`
TrackerURL *string `json:"tracker_url,omitempty"`
}
type ProjectConfigurationsResult struct {
ProjectUUID string `json:"project_uuid"`
Configs []models.Configuration `json:"configurations"`
Total float64 `json:"total"`
}
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")
}
now := time.Now()
localProject := &localdb.LocalProject{
UUID: uuid.NewString(),
OwnerUsername: ownerUsername,
Name: name,
TrackerURL: normalizeProjectTrackerURL(name, req.TrackerURL),
IsActive: true,
IsSystem: false,
CreatedAt: now,
UpdatedAt: now,
SyncStatus: "pending",
}
if err := s.localDB.SaveProject(localProject); err != nil {
return nil, err
}
if err := s.enqueueProjectPendingChange(localProject, "create"); err != nil {
return nil, err
}
return localdb.LocalToProject(localProject), nil
}
func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdateProjectRequest) (*models.Project, error) {
localProject, err := s.localDB.GetProjectByUUID(projectUUID)
if err != nil {
return nil, ErrProjectNotFound
}
if localProject.OwnerUsername != ownerUsername {
return nil, ErrProjectForbidden
}
name := strings.TrimSpace(req.Name)
if name == "" {
return nil, fmt.Errorf("project name is required")
}
localProject.Name = name
if req.TrackerURL != nil {
localProject.TrackerURL = normalizeProjectTrackerURL(name, *req.TrackerURL)
} else if strings.TrimSpace(localProject.TrackerURL) == "" {
localProject.TrackerURL = normalizeProjectTrackerURL(name, "")
}
localProject.UpdatedAt = time.Now()
localProject.SyncStatus = "pending"
if err := s.localDB.SaveProject(localProject); err != nil {
return nil, err
}
if err := s.enqueueProjectPendingChange(localProject, "update"); err != nil {
return nil, err
}
return localdb.LocalToProject(localProject), nil
}
func (s *ProjectService) Archive(projectUUID, ownerUsername string) error {
return s.setProjectActive(projectUUID, ownerUsername, false)
}
func (s *ProjectService) Reactivate(projectUUID, ownerUsername string) error {
return s.setProjectActive(projectUUID, ownerUsername, true)
}
func (s *ProjectService) setProjectActive(projectUUID, ownerUsername string, isActive bool) error {
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
var project localdb.LocalProject
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
}
project.IsActive = isActive
project.UpdatedAt = time.Now()
project.SyncStatus = "pending"
if err := tx.Save(&project).Error; err != nil {
return err
}
if err := s.enqueueProjectPendingChangeTx(tx, &project, boolToOp(isActive, "reactivate", "archive")); err != nil {
return err
}
var configs []localdb.LocalConfiguration
if err := tx.Where("project_uuid = ?", projectUUID).Find(&configs).Error; err != nil {
return err
}
for i := range configs {
cfg := configs[i]
cfg.IsActive = isActive
cfg.SyncStatus = "pending"
cfg.UpdatedAt = time.Now()
if err := tx.Save(&cfg).Error; err != nil {
return err
}
modelCfg := localdb.LocalToConfiguration(&cfg)
payload, err := json.Marshal(modelCfg)
if err != nil {
return err
}
change := &localdb.PendingChange{
EntityType: "configuration",
EntityUUID: cfg.UUID,
Operation: "update",
Payload: string(payload),
CreatedAt: time.Now(),
Attempts: 0,
}
if err := tx.Create(change).Error; err != nil {
return err
}
}
return nil
})
}
func (s *ProjectService) ListByUser(ownerUsername string, includeArchived bool) ([]models.Project, error) {
localProjects, err := s.localDB.GetAllProjects(includeArchived)
if err != nil {
return nil, err
}
projects := make([]models.Project, 0, len(localProjects))
for i := range localProjects {
projects = append(projects, *localdb.LocalToProject(&localProjects[i]))
}
return projects, nil
}
func (s *ProjectService) GetByUUID(projectUUID, ownerUsername string) (*models.Project, error) {
localProject, err := s.localDB.GetProjectByUUID(projectUUID)
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 {
return nil, err
}
if !project.IsActive && status == "active" {
return &ProjectConfigurationsResult{
ProjectUUID: projectUUID,
Configs: []models.Configuration{},
Total: 0,
}, nil
}
localConfigs, err := s.localDB.GetConfigurations()
if err != nil {
return nil, err
}
configs := make([]models.Configuration, 0, len(localConfigs))
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
}
configs = append(configs, *cfg)
}
return &ProjectConfigurationsResult{
ProjectUUID: projectUUID,
Configs: configs,
Total: total,
}, nil
}
func (s *ProjectService) ResolveProjectUUID(ownerUsername string, projectUUID *string) (*string, error) {
if projectUUID == nil || strings.TrimSpace(*projectUUID) == "" {
project, err := s.localDB.EnsureDefaultProject(ownerUsername)
if err != nil {
return nil, err
}
return &project.UUID, nil
}
project, err := s.localDB.GetProjectByUUID(strings.TrimSpace(*projectUUID))
if err != nil {
return nil, ErrProjectNotFound
}
if project.OwnerUsername != ownerUsername {
return nil, ErrProjectForbidden
}
if !project.IsActive {
return nil, fmt.Errorf("project is archived")
}
resolved := project.UUID
return &resolved, nil
}
func normalizeProjectTrackerURL(projectCode, trackerURL string) string {
trimmedURL := strings.TrimSpace(trackerURL)
if trimmedURL != "" {
return trimmedURL
}
trimmedCode := strings.TrimSpace(projectCode)
if trimmedCode == "" {
return ""
}
return "https://tracker.yandex.ru/" + url.PathEscape(trimmedCode)
}
func (s *ProjectService) enqueueProjectPendingChange(project *localdb.LocalProject, operation string) error {
return s.enqueueProjectPendingChangeTx(s.localDB.DB(), project, operation)
}
func (s *ProjectService) enqueueProjectPendingChangeTx(tx *gorm.DB, project *localdb.LocalProject, operation string) error {
payload := sync.ProjectChangePayload{
EventID: uuid.NewString(),
ProjectUUID: project.UUID,
Operation: operation,
Snapshot: *localdb.LocalToProject(project),
CreatedAt: time.Now().UTC(),
IdempotencyKey: fmt.Sprintf("%s:%d:%s", project.UUID, project.UpdatedAt.UnixNano(), operation),
}
raw, err := json.Marshal(payload)
if err != nil {
return err
}
change := &localdb.PendingChange{
EntityType: "project",
EntityUUID: project.UUID,
Operation: operation,
Payload: string(raw),
CreatedAt: time.Now(),
Attempts: 0,
}
return tx.Create(change).Error
}
func boolToOp(v bool, whenTrue, whenFalse string) string {
if v {
return whenTrue
}
return whenFalse
}

View File

@@ -3,6 +3,7 @@ package services
import (
"errors"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
@@ -17,17 +18,23 @@ var (
type QuoteService struct {
componentRepo *repository.ComponentRepository
statsRepo *repository.StatsRepository
pricelistRepo *repository.PricelistRepository
localDB *localdb.LocalDB
pricingService *pricing.Service
}
func NewQuoteService(
componentRepo *repository.ComponentRepository,
statsRepo *repository.StatsRepository,
pricelistRepo *repository.PricelistRepository,
localDB *localdb.LocalDB,
pricingService *pricing.Service,
) *QuoteService {
return &QuoteService{
componentRepo: componentRepo,
statsRepo: statsRepo,
pricelistRepo: pricelistRepo,
localDB: localDB,
pricingService: pricingService,
}
}
@@ -57,6 +64,34 @@ type QuoteRequest struct {
} `json:"items"`
}
type PriceLevelsRequest struct {
Items []struct {
LotName string `json:"lot_name"`
Quantity int `json:"quantity"`
} `json:"items"`
PricelistIDs map[string]uint `json:"pricelist_ids,omitempty"`
}
type PriceLevelsItem struct {
LotName string `json:"lot_name"`
Quantity int `json:"quantity"`
EstimatePrice *float64 `json:"estimate_price"`
WarehousePrice *float64 `json:"warehouse_price"`
CompetitorPrice *float64 `json:"competitor_price"`
DeltaWhEstimateAbs *float64 `json:"delta_wh_estimate_abs"`
DeltaWhEstimatePct *float64 `json:"delta_wh_estimate_pct"`
DeltaCompEstimateAbs *float64 `json:"delta_comp_estimate_abs"`
DeltaCompEstimatePct *float64 `json:"delta_comp_estimate_pct"`
DeltaCompWhAbs *float64 `json:"delta_comp_wh_abs"`
DeltaCompWhPct *float64 `json:"delta_comp_wh_pct"`
PriceMissing []string `json:"price_missing"`
}
type PriceLevelsResult struct {
Items []PriceLevelsItem `json:"items"`
ResolvedPricelistIDs map[string]uint `json:"resolved_pricelist_ids"`
}
func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidationResult, error) {
if len(req.Items) == 0 {
return nil, ErrEmptyQuote
@@ -130,8 +165,139 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
return result, nil
}
func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLevelsResult, error) {
if len(req.Items) == 0 {
return nil, ErrEmptyQuote
}
result := &PriceLevelsResult{
Items: make([]PriceLevelsItem, 0, len(req.Items)),
ResolvedPricelistIDs: map[string]uint{},
}
for _, reqItem := range req.Items {
item := PriceLevelsItem{
LotName: reqItem.LotName,
Quantity: reqItem.Quantity,
PriceMissing: make([]string, 0, 3),
}
estimatePrice, estimateID := s.lookupLevelPrice(models.PricelistSourceEstimate, reqItem.LotName, req.PricelistIDs)
warehousePrice, warehouseID := s.lookupLevelPrice(models.PricelistSourceWarehouse, reqItem.LotName, req.PricelistIDs)
competitorPrice, competitorID := s.lookupLevelPrice(models.PricelistSourceCompetitor, reqItem.LotName, req.PricelistIDs)
item.EstimatePrice = estimatePrice
item.WarehousePrice = warehousePrice
item.CompetitorPrice = competitorPrice
if estimateID != 0 {
result.ResolvedPricelistIDs[string(models.PricelistSourceEstimate)] = estimateID
}
if warehouseID != 0 {
result.ResolvedPricelistIDs[string(models.PricelistSourceWarehouse)] = warehouseID
}
if competitorID != 0 {
result.ResolvedPricelistIDs[string(models.PricelistSourceCompetitor)] = competitorID
}
if item.EstimatePrice == nil {
item.PriceMissing = append(item.PriceMissing, string(models.PricelistSourceEstimate))
}
if item.WarehousePrice == nil {
item.PriceMissing = append(item.PriceMissing, string(models.PricelistSourceWarehouse))
}
if item.CompetitorPrice == nil {
item.PriceMissing = append(item.PriceMissing, string(models.PricelistSourceCompetitor))
}
item.DeltaWhEstimateAbs, item.DeltaWhEstimatePct = calculateDelta(item.WarehousePrice, item.EstimatePrice)
item.DeltaCompEstimateAbs, item.DeltaCompEstimatePct = calculateDelta(item.CompetitorPrice, item.EstimatePrice)
item.DeltaCompWhAbs, item.DeltaCompWhPct = calculateDelta(item.CompetitorPrice, item.WarehousePrice)
result.Items = append(result.Items, item)
}
return result, nil
}
func calculateDelta(target, base *float64) (*float64, *float64) {
if target == nil || base == nil {
return nil, nil
}
abs := *target - *base
if *base == 0 {
return &abs, nil
}
pct := (abs / *base) * 100
return &abs, &pct
}
func (s *QuoteService) lookupLevelPrice(source models.PricelistSource, lotName string, pricelistIDs map[string]uint) (*float64, uint) {
sourceKey := string(source)
if id, ok := pricelistIDs[sourceKey]; ok && id > 0 {
price, found := s.lookupPriceByPricelistID(id, lotName)
if found {
return &price, id
}
return nil, id
}
if s.pricelistRepo != nil {
price, id, err := s.pricelistRepo.GetPriceForLotBySource(sourceKey, lotName)
if err == nil && price > 0 {
return &price, id
}
latest, latestErr := s.pricelistRepo.GetLatestActiveBySource(sourceKey)
if latestErr == nil {
return nil, latest.ID
}
}
if s.localDB != nil {
localPL, err := s.localDB.GetLatestLocalPricelistBySource(sourceKey)
if err != nil {
return nil, 0
}
price, err := s.localDB.GetLocalPriceForLot(localPL.ID, lotName)
if err != nil || price <= 0 {
return nil, localPL.ServerID
}
return &price, localPL.ServerID
}
return nil, 0
}
func (s *QuoteService) lookupPriceByPricelistID(pricelistID uint, lotName string) (float64, bool) {
if s.pricelistRepo != nil {
price, err := s.pricelistRepo.GetPriceForLot(pricelistID, lotName)
if err == nil && price > 0 {
return price, true
}
}
if s.localDB != nil {
localPL, err := s.localDB.GetLocalPricelistByServerID(pricelistID)
if err != nil {
return 0, false
}
price, err := s.localDB.GetLocalPriceForLot(localPL.ID, lotName)
if err == nil && price > 0 {
return price, true
}
}
return 0, false
}
// RecordUsage records that components were used in a quote
func (s *QuoteService) RecordUsage(items []models.ConfigItem) error {
if s.statsRepo == nil {
// Offline mode: usage stats are unavailable and should not block config saves.
return nil
}
for _, item := range items {
revenue := item.UnitPrice * float64(item.Quantity)
if err := s.statsRepo.IncrementUsage(item.LotName, item.Quantity, revenue); err != nil {

View File

@@ -0,0 +1,124 @@
package services
import (
"testing"
"time"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestCalculatePriceLevels_WithMissingLevel(t *testing.T) {
db := newPriceLevelsTestDB(t)
repo := repository.NewPricelistRepository(db)
service := NewQuoteService(nil, nil, repo, nil, nil)
estimate := seedPricelistWithItem(t, repo, "estimate", "CPU_X", 100)
_ = estimate
seedPricelistWithItem(t, repo, "warehouse", "CPU_X", 120)
result, err := service.CalculatePriceLevels(&PriceLevelsRequest{
Items: []struct {
LotName string `json:"lot_name"`
Quantity int `json:"quantity"`
}{
{LotName: "CPU_X", Quantity: 2},
},
})
if err != nil {
t.Fatalf("CalculatePriceLevels returned error: %v", err)
}
if len(result.Items) != 1 {
t.Fatalf("expected 1 item, got %d", len(result.Items))
}
item := result.Items[0]
if item.EstimatePrice == nil || *item.EstimatePrice != 100 {
t.Fatalf("expected estimate 100, got %#v", item.EstimatePrice)
}
if item.WarehousePrice == nil || *item.WarehousePrice != 120 {
t.Fatalf("expected warehouse 120, got %#v", item.WarehousePrice)
}
if item.CompetitorPrice != nil {
t.Fatalf("expected competitor nil, got %#v", item.CompetitorPrice)
}
if len(item.PriceMissing) != 1 || item.PriceMissing[0] != "competitor" {
t.Fatalf("expected price_missing [competitor], got %#v", item.PriceMissing)
}
if item.DeltaWhEstimateAbs == nil || *item.DeltaWhEstimateAbs != 20 {
t.Fatalf("expected delta abs 20, got %#v", item.DeltaWhEstimateAbs)
}
if item.DeltaWhEstimatePct == nil || *item.DeltaWhEstimatePct != 20 {
t.Fatalf("expected delta pct 20, got %#v", item.DeltaWhEstimatePct)
}
}
func TestCalculatePriceLevels_UsesExplicitPricelistIDs(t *testing.T) {
db := newPriceLevelsTestDB(t)
repo := repository.NewPricelistRepository(db)
service := NewQuoteService(nil, nil, repo, nil, nil)
olderEstimate := seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 80)
seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 90)
result, err := service.CalculatePriceLevels(&PriceLevelsRequest{
Items: []struct {
LotName string `json:"lot_name"`
Quantity int `json:"quantity"`
}{
{LotName: "CPU_Y", Quantity: 1},
},
PricelistIDs: map[string]uint{
"estimate": olderEstimate.ID,
},
})
if err != nil {
t.Fatalf("CalculatePriceLevels returned error: %v", err)
}
item := result.Items[0]
if item.EstimatePrice == nil || *item.EstimatePrice != 80 {
t.Fatalf("expected explicit estimate 80, got %#v", item.EstimatePrice)
}
}
func newPriceLevelsTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.Pricelist{}, &models.PricelistItem{}); err != nil {
t.Fatalf("migrate: %v", err)
}
return db
}
func seedPricelistWithItem(t *testing.T, repo *repository.PricelistRepository, source, lot string, price float64) *models.Pricelist {
t.Helper()
version, err := repo.GenerateVersionBySource(source)
if err != nil {
t.Fatalf("GenerateVersionBySource: %v", err)
}
expiresAt := time.Now().Add(24 * time.Hour)
pl := &models.Pricelist{
Source: source,
Version: version,
CreatedBy: "test",
IsActive: true,
ExpiresAt: &expiresAt,
}
if err := repo.Create(pl); err != nil {
t.Fatalf("create pricelist: %v", err)
}
if err := repo.CreateItems([]models.PricelistItem{
{
PricelistID: pl.ID,
LotName: lot,
Price: price,
},
}); err != nil {
t.Fatalf("create items: %v", err)
}
return pl
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
package sync
import (
"testing"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
)
func TestPrioritizeProjectChanges(t *testing.T) {
changes := []localdb.PendingChange{
{ID: 1, EntityType: "configuration"},
{ID: 2, EntityType: "project"},
{ID: 3, EntityType: "configuration"},
{ID: 4, EntityType: "project"},
}
sorted := prioritizeProjectChanges(changes)
if len(sorted) != 4 {
t.Fatalf("unexpected sorted length: %d", len(sorted))
}
if sorted[0].EntityType != "project" || sorted[1].EntityType != "project" {
t.Fatalf("expected project changes first, got order: %s, %s", sorted[0].EntityType, sorted[1].EntityType)
}
}

View File

@@ -0,0 +1,374 @@
package sync_test
import (
"encoding/json"
"fmt"
"path/filepath"
"testing"
"time"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/services"
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestPushPendingChangesProjectsBeforeConfigurations(t *testing.T) {
local := newLocalDBForSyncTest(t)
serverDB := newServerDBForSyncTest(t)
localSync := syncsvc.NewService(nil, local)
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"})
if err != nil {
t.Fatalf("create project: %v", err)
}
cfg, err := configService.Create("tester", &services.CreateConfigRequest{
Name: "Cfg A",
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}},
ServerCount: 1,
ProjectUUID: &project.UUID,
})
if err != nil {
t.Fatalf("create config: %v", err)
}
pushService := syncsvc.NewServiceWithDB(serverDB, local)
pushed, err := pushService.PushPendingChanges()
if err != nil {
t.Fatalf("push pending changes: %v", err)
}
if pushed < 2 {
t.Fatalf("expected at least 2 pushed changes, got %d", pushed)
}
var serverProject models.Project
if err := serverDB.Where("uuid = ?", project.UUID).First(&serverProject).Error; err != nil {
t.Fatalf("project not pushed to server: %v", err)
}
var serverCfg models.Configuration
if err := serverDB.Where("uuid = ?", cfg.UUID).First(&serverCfg).Error; err != nil {
t.Fatalf("configuration not pushed to server: %v", err)
}
if serverCfg.ProjectUUID == nil || *serverCfg.ProjectUUID != project.UUID {
t.Fatalf("expected project_uuid=%s on pushed config, got %v", project.UUID, serverCfg.ProjectUUID)
}
if got := local.CountPendingChanges(); got != 0 {
t.Fatalf("expected pending queue to be empty, got %d", got)
}
}
func TestPushPendingChangesProjectCreateThenUpdateBeforeFirstPush(t *testing.T) {
local := newLocalDBForSyncTest(t)
serverDB := newServerDBForSyncTest(t)
localSync := syncsvc.NewService(nil, local)
projectService := services.NewProjectService(local)
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"})
if err != nil {
t.Fatalf("create project: %v", err)
}
if _, err := projectService.Update(project.UUID, "tester", &services.UpdateProjectRequest{Name: "Project v2"}); err != nil {
t.Fatalf("update project: %v", err)
}
cfg, err := configService.Create("tester", &services.CreateConfigRequest{
Name: "Cfg linked",
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}},
ServerCount: 1,
ProjectUUID: &project.UUID,
})
if err != nil {
t.Fatalf("create config: %v", err)
}
if _, err := pushService.PushPendingChanges(); err != nil {
t.Fatalf("push pending changes: %v", err)
}
var serverProject models.Project
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)
}
var serverCfg models.Configuration
if err := serverDB.Where("uuid = ?", cfg.UUID).First(&serverCfg).Error; err != nil {
t.Fatalf("configuration not pushed to server: %v", err)
}
if serverCfg.ProjectUUID == nil || *serverCfg.ProjectUUID != project.UUID {
t.Fatalf("expected project_uuid=%s on pushed config, got %v", project.UUID, serverCfg.ProjectUUID)
}
}
func TestPushPendingChangesSkipsStaleUpdateAndAppliesLatest(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 v1",
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}},
ServerCount: 1,
})
if err != nil {
t.Fatalf("create config: %v", err)
}
if _, err := pushService.PushPendingChanges(); err != nil {
t.Fatalf("initial push: %v", err)
}
if _, err := configService.UpdateNoAuth(created.UUID, &services.CreateConfigRequest{
Name: "Cfg v2",
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 1000}},
ServerCount: 1,
ProjectUUID: created.ProjectUUID,
}); err != nil {
t.Fatalf("update config: %v", err)
}
localCfg, err := local.GetConfigurationByUUID(created.UUID)
if err != nil {
t.Fatalf("get local config: %v", err)
}
cfgSnapshot := localdb.LocalToConfiguration(localCfg)
stalePayload := syncsvc.ConfigurationChangePayload{
EventID: "stale-event",
IdempotencyKey: fmt.Sprintf("%s:v1:update", created.UUID),
ConfigurationUUID: created.UUID,
ProjectUUID: cfgSnapshot.ProjectUUID,
Operation: "update",
CurrentVersionID: "stale-v1",
CurrentVersionNo: 1,
ConflictPolicy: "last_write_wins",
Snapshot: *cfgSnapshot,
CreatedAt: time.Now().UTC().Add(-2 * time.Second),
}
raw, err := json.Marshal(stalePayload)
if err != nil {
t.Fatalf("marshal stale payload: %v", err)
}
if err := local.DB().Create(&localdb.PendingChange{
EntityType: "configuration",
EntityUUID: created.UUID,
Operation: "update",
Payload: string(raw),
CreatedAt: time.Now().Add(-1 * time.Second),
}).Error; err != nil {
t.Fatalf("insert stale pending change: %v", err)
}
if _, err := pushService.PushPendingChanges(); err != nil {
t.Fatalf("push pending with stale event: %v", err)
}
var serverCfg models.Configuration
if err := serverDB.Where("uuid = ?", created.UUID).First(&serverCfg).Error; err != nil {
t.Fatalf("get server config: %v", err)
}
if serverCfg.Name != "Cfg v2" {
t.Fatalf("expected latest name to win, got %q", serverCfg.Name)
}
if got := local.CountPendingChanges(); got != 0 {
t.Fatalf("expected empty pending queue, got %d", got)
}
}
func TestPushPendingChangesCreateIsIdempotent(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 Idempotent",
Items: models.ConfigItems{{LotName: "CPU_B", Quantity: 1, UnitPrice: 500}},
ServerCount: 1,
})
if err != nil {
t.Fatalf("create config: %v", err)
}
if _, err := pushService.PushPendingChanges(); err != nil {
t.Fatalf("initial push: %v", err)
}
localCfg, err := local.GetConfigurationByUUID(created.UUID)
if err != nil {
t.Fatalf("get local config: %v", err)
}
currentVersionNo, currentVersionID := getCurrentVersionInfo(t, local, created.UUID, localCfg.CurrentVersionID)
cfgSnapshot := localdb.LocalToConfiguration(localCfg)
duplicatePayload := syncsvc.ConfigurationChangePayload{
EventID: "duplicate-create-event",
IdempotencyKey: fmt.Sprintf("%s:v%d:create", created.UUID, currentVersionNo),
ConfigurationUUID: created.UUID,
ProjectUUID: cfgSnapshot.ProjectUUID,
Operation: "create",
CurrentVersionID: currentVersionID,
CurrentVersionNo: currentVersionNo,
ConflictPolicy: "last_write_wins",
Snapshot: *cfgSnapshot,
CreatedAt: time.Now().UTC(),
}
raw, err := json.Marshal(duplicatePayload)
if err != nil {
t.Fatalf("marshal duplicate payload: %v", err)
}
if err := local.AddPendingChange("configuration", created.UUID, "create", string(raw)); err != nil {
t.Fatalf("add duplicate create pending change: %v", err)
}
if pushed, err := pushService.PushPendingChanges(); err != nil {
t.Fatalf("push duplicate create: %v", err)
} else if pushed != 1 {
t.Fatalf("expected 1 pushed change for duplicate create, got %d", pushed)
}
var count int64
if err := serverDB.Model(&models.Configuration{}).Where("uuid = ?", created.UUID).Count(&count).Error; err != nil {
t.Fatalf("count server configs: %v", err)
}
if count != 1 {
t.Fatalf("expected one server row after idempotent create, got %d", count)
}
}
func TestPushPendingChangesCreateThenUpdateBeforeFirstPush(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 v1",
Items: models.ConfigItems{{LotName: "CPU_X", Quantity: 1, UnitPrice: 700}},
ServerCount: 1,
})
if err != nil {
t.Fatalf("create config: %v", err)
}
if _, err := configService.UpdateNoAuth(created.UUID, &services.CreateConfigRequest{
Name: "Cfg v2",
Items: models.ConfigItems{{LotName: "CPU_X", Quantity: 3, UnitPrice: 700}},
ServerCount: 1,
ProjectUUID: created.ProjectUUID,
}); err != nil {
t.Fatalf("update config before first push: %v", err)
}
pushed, err := pushService.PushPendingChanges()
if err != nil {
t.Fatalf("push pending changes: %v", err)
}
if pushed < 1 {
t.Fatalf("expected at least one pushed change, got %d", pushed)
}
var serverCfg models.Configuration
if err := serverDB.Where("uuid = ?", created.UUID).First(&serverCfg).Error; err != nil {
t.Fatalf("configuration not pushed to server: %v", err)
}
if serverCfg.Name != "Cfg v2" {
t.Fatalf("expected latest update to be pushed, got %q", serverCfg.Name)
}
localCfg, err := local.GetConfigurationByUUID(created.UUID)
if err != nil {
t.Fatalf("get local config: %v", err)
}
if localCfg.ServerID == nil || *localCfg.ServerID == 0 {
t.Fatalf("expected local configuration to have server_id after push, got %+v", localCfg.ServerID)
}
}
func newLocalDBForSyncTest(t *testing.T) *localdb.LocalDB {
t.Helper()
localPath := filepath.Join(t.TempDir(), "local.db")
local, err := localdb.New(localPath)
if err != nil {
t.Fatalf("init local db: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
return local
}
func newServerDBForSyncTest(t *testing.T) *gorm.DB {
t.Helper()
serverPath := filepath.Join(t.TempDir(), "server.db")
db, err := gorm.Open(sqlite.Open(serverPath), &gorm.Config{})
if err != nil {
t.Fatalf("open server sqlite: %v", err)
}
if err := db.Exec(`
CREATE TABLE qt_projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
owner_username TEXT NOT NULL,
name TEXT NOT 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
);`).Error; err != nil {
t.Fatalf("create qt_projects: %v", err)
}
if err := db.Exec(`
CREATE TABLE qt_configurations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
user_id INTEGER NULL,
owner_username TEXT NOT NULL,
project_uuid TEXT NULL,
app_version TEXT NULL,
name TEXT NOT NULL,
items TEXT NOT NULL,
total_price REAL NULL,
custom_price REAL NULL,
notes TEXT NULL,
is_template INTEGER NOT NULL DEFAULT 0,
server_count INTEGER NOT NULL DEFAULT 1,
pricelist_id INTEGER NULL,
price_updated_at DATETIME NULL,
created_at DATETIME
);`).Error; err != nil {
t.Fatalf("create qt_configurations: %v", err)
}
return db
}
func getCurrentVersionInfo(t *testing.T, local *localdb.LocalDB, configurationUUID string, currentVersionID *string) (int, string) {
t.Helper()
if currentVersionID == nil || *currentVersionID == "" {
t.Fatalf("current version id is empty for %s", configurationUUID)
}
var version localdb.LocalConfigurationVersion
if err := local.DB().
Where("id = ? AND configuration_uuid = ?", *currentVersionID, configurationUUID).
First(&version).Error; err != nil {
t.Fatalf("get current version info: %v", err)
}
return version.VersionNo, version.ID
}

View File

@@ -83,7 +83,11 @@ func (w *Worker) runSync() {
err = w.service.SyncPricelistsIfNeeded()
if err != nil {
w.logger.Warn("background sync: failed to sync pricelists", "error", err)
return
}
// Mark user's sync heartbeat (used for online/offline status in UI).
w.service.RecordSyncHeartbeat()
w.logger.Info("background sync cycle completed")
}

View File

@@ -0,0 +1,10 @@
-- Store configuration owner as username (instead of relying on numeric user_id)
ALTER TABLE qt_configurations
ADD COLUMN owner_username VARCHAR(100) NOT NULL DEFAULT '' AFTER user_id,
ADD INDEX idx_qt_configurations_owner_username (owner_username);
-- Backfill owner_username from qt_users for existing rows
UPDATE qt_configurations c
LEFT JOIN qt_users u ON u.id = c.user_id
SET c.owner_username = COALESCE(u.username, c.owner_username)
WHERE c.owner_username = '';

View File

@@ -0,0 +1,80 @@
-- Add full-snapshot versioning for local configurations (SQLite)
-- 1) Create local_configuration_versions
-- 2) Add current_version_id to local_configurations
-- 3) Backfill v1 snapshots from existing local_configurations
PRAGMA foreign_keys = ON;
BEGIN TRANSACTION;
CREATE TABLE local_configuration_versions (
id TEXT PRIMARY KEY,
configuration_uuid TEXT NOT NULL,
version_no INTEGER NOT NULL,
data TEXT NOT NULL,
change_note TEXT NULL,
created_by TEXT NULL,
app_version TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (configuration_uuid) REFERENCES local_configurations(uuid),
UNIQUE(configuration_uuid, version_no)
);
ALTER TABLE local_configurations
ADD COLUMN current_version_id TEXT NULL;
CREATE INDEX idx_lcv_config_created
ON local_configuration_versions(configuration_uuid, created_at DESC);
CREATE INDEX idx_lcv_config_version
ON local_configuration_versions(configuration_uuid, version_no DESC);
-- Backfill v1 snapshot for every existing configuration.
INSERT INTO local_configuration_versions (
id,
configuration_uuid,
version_no,
data,
change_note,
created_by,
app_version,
created_at
)
SELECT
uuid || '-v1' AS id,
uuid AS configuration_uuid,
1 AS version_no,
json_object(
'uuid', uuid,
'server_id', server_id,
'name', name,
'items', CASE WHEN json_valid(items) THEN json(items) ELSE items END,
'total_price', total_price,
'custom_price', custom_price,
'notes', notes,
'is_template', is_template,
'server_count', server_count,
'price_updated_at', price_updated_at,
'created_at', created_at,
'updated_at', updated_at,
'synced_at', synced_at,
'sync_status', sync_status,
'original_user_id', original_user_id,
'original_username', original_username,
'app_version', NULL
) AS data,
'Initial snapshot backfill (v1)' AS change_note,
NULL AS created_by,
NULL AS app_version,
COALESCE(created_at, CURRENT_TIMESTAMP) AS created_at
FROM local_configurations;
UPDATE local_configurations
SET current_version_id = (
SELECT lcv.id
FROM local_configuration_versions lcv
WHERE lcv.configuration_uuid = local_configurations.uuid
AND lcv.version_no = 1
);
COMMIT;

View File

@@ -0,0 +1,25 @@
-- Detach qt_configurations from qt_users (ownership is owner_username text)
-- Safe for MySQL 8+/MariaDB 10.2+ via INFORMATION_SCHEMA checks.
SET @fk_exists := (
SELECT COUNT(*)
FROM information_schema.TABLE_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE()
AND TABLE_NAME = 'qt_configurations'
AND CONSTRAINT_NAME = 'fk_qt_configurations_user'
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
);
SET @drop_fk_sql := IF(
@fk_exists > 0,
'ALTER TABLE qt_configurations DROP FOREIGN KEY fk_qt_configurations_user',
'SELECT ''fk_qt_configurations_user not found, skip'' '
);
PREPARE stmt_drop_fk FROM @drop_fk_sql;
EXECUTE stmt_drop_fk;
DEALLOCATE PREPARE stmt_drop_fk;
-- user_id becomes optional legacy column (can stay NULL)
ALTER TABLE qt_configurations
MODIFY COLUMN user_id BIGINT UNSIGNED NULL;

View File

@@ -0,0 +1,4 @@
-- Track application version used for configuration writes (create/update via sync)
ALTER TABLE qt_configurations
ADD COLUMN app_version VARCHAR(64) NULL DEFAULT NULL AFTER owner_username;

View File

@@ -0,0 +1,45 @@
-- Add projects and attach configurations to projects
CREATE TABLE qt_projects (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
uuid CHAR(36) NOT NULL UNIQUE,
owner_username VARCHAR(100) NOT NULL,
name VARCHAR(200) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
is_system BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_qt_projects_owner_username (owner_username),
INDEX idx_qt_projects_is_active (is_active),
INDEX idx_qt_projects_is_system (is_system)
);
ALTER TABLE qt_configurations
ADD COLUMN project_uuid CHAR(36) NULL AFTER app_version,
ADD INDEX idx_qt_configurations_project_uuid (project_uuid),
ADD CONSTRAINT fk_qt_configurations_project_uuid
FOREIGN KEY (project_uuid) REFERENCES qt_projects(uuid)
ON UPDATE CASCADE
ON DELETE SET NULL;
-- One system project per owner: "Без проекта"
INSERT INTO qt_projects (uuid, owner_username, name, is_active, is_system, created_at, updated_at)
SELECT UUID(), owners.owner_username, 'Без проекта', TRUE, TRUE, NOW(), NOW()
FROM (
SELECT DISTINCT owner_username
FROM qt_configurations
) AS owners
LEFT JOIN qt_projects p
ON p.owner_username = owners.owner_username
AND p.name = 'Без проекта'
AND p.is_system = TRUE
WHERE p.id IS NULL;
-- Attach all existing configurations without project to the system project
UPDATE qt_configurations c
JOIN qt_projects p
ON p.owner_username = c.owner_username
AND p.name = 'Без проекта'
AND p.is_system = TRUE
SET c.project_uuid = p.uuid
WHERE c.project_uuid IS NULL OR c.project_uuid = '';

View File

@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS qt_pricelist_sync_status (
username VARCHAR(100) NOT NULL,
last_sync_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
app_version VARCHAR(64) NULL,
PRIMARY KEY (username),
INDEX idx_qt_pricelist_sync_status_last_sync (last_sync_at)
);

View File

@@ -0,0 +1,37 @@
-- Add pricelist binding to configurations
ALTER TABLE qt_configurations
ADD COLUMN pricelist_id BIGINT UNSIGNED NULL AFTER server_count;
ALTER TABLE qt_configurations
ADD INDEX idx_qt_configurations_pricelist_id (pricelist_id),
ADD CONSTRAINT fk_qt_configurations_pricelist_id
FOREIGN KEY (pricelist_id)
REFERENCES qt_pricelists(id)
ON DELETE RESTRICT
ON UPDATE CASCADE;
-- Backfill existing configurations to latest active pricelist
SET @latest_active_pricelist_id := (
SELECT id
FROM qt_pricelists
WHERE is_active = 1
ORDER BY created_at DESC
LIMIT 1
);
UPDATE qt_configurations
SET pricelist_id = @latest_active_pricelist_id
WHERE pricelist_id IS NULL
AND @latest_active_pricelist_id IS NOT NULL;
-- Recalculate usage_count from configuration bindings
UPDATE qt_pricelists SET usage_count = 0;
UPDATE qt_pricelists pl
JOIN (
SELECT pricelist_id, COUNT(*) AS cnt
FROM qt_configurations
WHERE pricelist_id IS NOT NULL
GROUP BY pricelist_id
) cfg ON cfg.pricelist_id = pl.id
SET pl.usage_count = cfg.cnt;

View File

@@ -0,0 +1,2 @@
ALTER TABLE qt_pricelist_sync_status
ADD COLUMN IF NOT EXISTS app_version VARCHAR(64) NULL;

View File

@@ -0,0 +1,7 @@
ALTER TABLE qt_projects
ADD COLUMN tracker_url VARCHAR(500) NULL AFTER name;
UPDATE qt_projects
SET tracker_url = CONCAT('https://tracker.yandex.ru/', TRIM(name))
WHERE (tracker_url IS NULL OR tracker_url = '')
AND TRIM(COALESCE(name, '')) <> '';

View File

@@ -0,0 +1,15 @@
ALTER TABLE qt_pricelists
ADD COLUMN IF NOT EXISTS source ENUM('estimate', 'warehouse', 'competitor') NOT NULL DEFAULT 'estimate' AFTER id;
UPDATE qt_pricelists
SET source = 'estimate'
WHERE source IS NULL OR source = '';
ALTER TABLE qt_pricelists
DROP INDEX IF EXISTS idx_qt_pricelists_version;
CREATE UNIQUE INDEX idx_qt_pricelists_source_version
ON qt_pricelists(source, version);
CREATE INDEX idx_qt_pricelists_source_created_at
ON qt_pricelists(source, created_at);

View File

@@ -0,0 +1,51 @@
# QuoteForge v1.0.3
Дата релиза: 2026-02-06
Тег: `v1.0.3`
Диапазон изменений: `v1.0.2..v1.0.3`
## Что нового
- Добавлена страница управления проектами `/projects` с:
- датой и временем создания проекта;
- сортировкой по названию и дате создания;
- серверной пагинацией;
- фильтром по автору в заголовке таблицы.
- Добавлена отдельная вкладка `Статус синхронизации` на уровне `Алерты / Компоненты / Прайслисты`.
- Во вкладке статуса синхронизации отображаются:
- пользователь;
- версия приложения;
- статус (`онлайн` или относительное время последней синхронизации).
## Изменения синхронизации
- Реализован heartbeat синхронизации пользователей в MariaDB: `qt_pricelist_sync_status`.
- Добавлен API `GET /api/sync/users-status` для UI статуса синхронизации.
- Логика онлайн-статуса рассчитана от интервала фоновой синхронизации: `5 минут + 10%`.
- В heartbeat фиксируется версия приложения (`app_version`).
## Важные исправления
- Исправлено восстановление отсутствующей серверной конфигурации при push обновлений.
- Исправлено экранирование паролей в MySQL DSN в setup.
- Улучшена логика запуска SQL-миграций на старте при отсутствии прав/необходимости.
- Обновлена логика пересчета прайслистов через админский price-refresh.
## Миграции и совместимость
Добавлены SQL-миграции:
- `migrations/010_add_pricelist_sync_status.sql`
- `migrations/011_add_app_version_to_pricelist_sync_status.sql`
Релиз совместим с предыдущей веткой `v1.0.x`; новая таблица синхронизации создается автоматически.
## Коммиты в релизе
- `b1b50ce` Add projects table controls and sync status tab with app version
- `6ab1e98` sync: recover missing server config during update push
- `a1d2192` Fix MySQL DSN escaping for setup passwords and clarify DB user setup
- `a90c07c` update stale files list
- `e9307c4` Apply remaining pricelist and local-first updates
- `1b48401` Use admin price-refresh logic for pricelist recalculation
- `4a86f7b` fix: skip startup sql migrations when not needed or no permissions

100
scripts/release.sh Executable file
View File

@@ -0,0 +1,100 @@
#!/bin/bash
set -e
# QuoteForge Release Build Script
# Creates binaries for all platforms and packages them for release
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Get version from git
VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "dev")
if [[ $VERSION == *"dirty"* ]]; then
echo -e "${RED}✗ Error: Working directory has uncommitted changes${NC}"
echo " Commit your changes first"
exit 1
fi
echo -e "${GREEN}Building QuoteForge version: ${VERSION}${NC}"
echo ""
# Create release directory
RELEASE_DIR="releases/${VERSION}"
mkdir -p "${RELEASE_DIR}"
# Build for all platforms
echo -e "${YELLOW}→ Building binaries...${NC}"
make build-all
# Package binaries with checksums
echo ""
echo -e "${YELLOW}→ Creating release packages...${NC}"
# Linux AMD64
if [ -f "bin/qfs-linux-amd64" ]; then
cd bin
tar -czf "../${RELEASE_DIR}/qfs-${VERSION}-linux-amd64.tar.gz" qfs-linux-amd64
cd ..
echo -e "${GREEN} ✓ qfs-${VERSION}-linux-amd64.tar.gz${NC}"
fi
# macOS Intel
if [ -f "bin/qfs-darwin-amd64" ]; then
cd bin
tar -czf "../${RELEASE_DIR}/qfs-${VERSION}-darwin-amd64.tar.gz" qfs-darwin-amd64
cd ..
echo -e "${GREEN} ✓ qfs-${VERSION}-darwin-amd64.tar.gz${NC}"
fi
# macOS Apple Silicon
if [ -f "bin/qfs-darwin-arm64" ]; then
cd bin
tar -czf "../${RELEASE_DIR}/qfs-${VERSION}-darwin-arm64.tar.gz" qfs-darwin-arm64
cd ..
echo -e "${GREEN} ✓ qfs-${VERSION}-darwin-arm64.tar.gz${NC}"
fi
# Windows AMD64
if [ -f "bin/qfs-windows-amd64.exe" ]; then
cd bin
zip -q "../${RELEASE_DIR}/qfs-${VERSION}-windows-amd64.zip" qfs-windows-amd64.exe
cd ..
echo -e "${GREEN} ✓ qfs-${VERSION}-windows-amd64.zip${NC}"
fi
# Generate checksums
echo ""
echo -e "${YELLOW}→ Generating checksums...${NC}"
cd "${RELEASE_DIR}"
shasum -a 256 *.tar.gz *.zip > SHA256SUMS.txt 2>/dev/null || shasum -a 256 * | grep -v SHA256SUMS > SHA256SUMS.txt
cd ../..
echo -e "${GREEN} ✓ SHA256SUMS.txt${NC}"
# List release files
echo ""
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${GREEN}Release ${VERSION} built successfully!${NC}"
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
echo "Files in ${RELEASE_DIR}:"
ls -lh "${RELEASE_DIR}"
echo ""
# Show next steps
echo -e "${YELLOW}Next steps:${NC}"
echo " 1. Create git tag:"
echo " git tag -a ${VERSION} -m \"Release ${VERSION}\""
echo ""
echo " 2. Push tag to remote:"
echo " git push origin ${VERSION}"
echo ""
echo " 3. Create release on git.mchus.pro:"
echo " - Go to: https://git.mchus.pro/mchus/QuoteForge/releases"
echo " - Click 'New Release'"
echo " - Select tag: ${VERSION}"
echo " - Upload files from: ${RELEASE_DIR}/"
echo ""
echo -e "${GREEN}Done!${NC}"

View File

@@ -10,17 +10,18 @@
<button onclick="loadTab('alerts')" id="btn-alerts" class="text-blue-600 font-medium">Алерты</button>
<button onclick="loadTab('components')" id="btn-components" class="text-gray-600">Компоненты</button>
<button onclick="loadTab('pricelists')" id="btn-pricelists" class="text-gray-600">Прайслисты</button>
<button onclick="loadTab('sync-status')" id="btn-sync-status" class="text-gray-600 hidden">Статус синхронизации</button>
<button onclick="loadTab('all-configs')" id="btn-all-configs" class="text-gray-600 hidden">Все конфигурации</button>
</div>
<button onclick="recalculateAll()" id="btn-recalc" class="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700">
Пересчитать цены
Обновить цены
</button>
</div>
<!-- Progress bar -->
<div id="progress-container" class="mb-4 p-4 bg-blue-50 rounded-lg border border-blue-200" style="display:none;">
<div class="flex justify-between text-sm text-gray-700 mb-2">
<span id="progress-text" class="font-medium">Пересчёт цен...</span>
<span id="progress-text" class="font-medium">Обновление цен...</span>
<span id="progress-percent" class="font-bold">0%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-4">
@@ -85,6 +86,30 @@
<div id="pricelists-pagination" class="flex justify-center space-x-2 mt-4"></div>
</div>
<!-- Sync Status Tab Content (hidden by default) -->
<div id="sync-status-tab-content" class="hidden">
<div class="mb-4">
<h2 class="text-xl font-semibold">Статус синхронизации</h2>
</div>
<div class="bg-white rounded-lg shadow overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Пользователь</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Версия приложения</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Статус</th>
</tr>
</thead>
<tbody id="sync-users-status-body" class="bg-white divide-y divide-gray-200">
<tr>
<td colspan="3" class="px-6 py-4 text-sm text-gray-500">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Create Modal -->
<div id="pricelists-create-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg p-6 max-w-md w-full mx-4">
@@ -94,6 +119,16 @@
Автор прайслиста: <span id="pricelists-db-username" class="font-medium">загрузка...</span>
</p>
<form id="pricelists-create-form" class="space-y-4">
<div id="pricelist-create-progress" class="hidden p-3 bg-blue-50 rounded-lg border border-blue-200">
<div class="flex justify-between items-center text-sm mb-2">
<span id="pricelist-create-progress-text" class="font-medium">Подготовка...</span>
<span id="pricelist-create-progress-percent" class="font-bold">0%</span>
</div>
<div class="w-full bg-blue-100 rounded-full h-3 overflow-hidden">
<div id="pricelist-create-progress-bar" class="bg-blue-600 h-3 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
<div id="pricelist-create-progress-stats" class="text-xs text-gray-600 mt-2"></div>
</div>
<div class="flex justify-end space-x-3">
<button type="button" onclick="closePricelistsCreateModal()"
class="px-4 py-2 text-gray-700 border border-gray-300 rounded-md hover:bg-gray-50">
@@ -216,16 +251,21 @@ let pricelistsPage = 1;
let pricelistsCanWrite = false;
let isCreatingPricelist = false;
let cachedDbUsername = null;
let syncUsersStatusTimer = null;
async function loadTab(tab) {
currentTab = tab;
currentPage = 1;
currentSearch = '';
document.getElementById('search-input').value = '';
if (tab !== 'sync-status') {
stopSyncUsersStatusRefresh();
}
document.getElementById('btn-alerts').className = tab === 'alerts' ? 'text-blue-600 font-medium' : 'text-gray-600';
document.getElementById('btn-components').className = tab === 'components' ? 'text-blue-600 font-medium' : 'text-gray-600';
document.getElementById('btn-pricelists').className = tab === 'pricelists' ? 'text-blue-600 font-medium' : 'text-gray-600';
document.getElementById('btn-sync-status').className = (tab === 'sync-status' ? 'text-blue-600 font-medium' : 'text-gray-600') + (pricelistsCanWrite ? '' : ' hidden');
document.getElementById('btn-all-configs').className = tab === 'all-configs' ? 'text-blue-600 font-medium' : 'text-gray-600 hidden';
// Show/hide elements based on tab
@@ -234,35 +274,69 @@ async function loadTab(tab) {
document.getElementById('pagination').className = 'flex justify-between items-center mt-4 pt-4 border-t';
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden'; // Hide this tab for components
document.getElementById('pricelists-tab-content').className = 'hidden';
document.getElementById('sync-status-tab-content').className = 'hidden';
document.getElementById('tab-content').className = '';
} else if (tab === 'all-configs') {
document.getElementById('search-bar').className = 'mb-4 hidden'; // Hide search for all configs
document.getElementById('pagination').className = 'flex justify-between items-center mt-4 pt-4 border-t'; // Show pagination
document.getElementById('btn-all-configs').className = 'text-blue-600 font-medium'; // Show this tab for all configs
document.getElementById('pricelists-tab-content').className = 'hidden';
document.getElementById('sync-status-tab-content').className = 'hidden';
document.getElementById('tab-content').className = '';
} else if (tab === 'pricelists') {
document.getElementById('search-bar').className = 'mb-4 hidden';
document.getElementById('pagination').className = 'hidden';
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
document.getElementById('pricelists-tab-content').className = '';
document.getElementById('sync-status-tab-content').className = 'hidden';
document.getElementById('tab-content').className = 'hidden';
// Load pricelists when pricelists tab is selected
checkPricelistWritePermission();
loadPricelists(1);
} else if (tab === 'sync-status') {
document.getElementById('search-bar').className = 'mb-4 hidden';
document.getElementById('pagination').className = 'hidden';
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
document.getElementById('pricelists-tab-content').className = 'hidden';
document.getElementById('sync-status-tab-content').className = '';
document.getElementById('tab-content').className = 'hidden';
await checkPricelistWritePermission();
if (!pricelistsCanWrite) {
await loadTab('alerts');
return;
}
await loadUsersSyncStatus();
startSyncUsersStatusRefresh();
} else {
document.getElementById('search-bar').className = 'mb-4 hidden';
document.getElementById('pagination').className = 'hidden';
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
document.getElementById('pricelists-tab-content').className = 'hidden';
document.getElementById('sync-status-tab-content').className = 'hidden';
document.getElementById('tab-content').className = '';
}
if (tab !== 'pricelists') {
if (tab !== 'pricelists' && tab !== 'sync-status') {
await loadData();
}
}
function stopSyncUsersStatusRefresh() {
if (syncUsersStatusTimer) {
clearInterval(syncUsersStatusTimer);
syncUsersStatusTimer = null;
}
}
function startSyncUsersStatusRefresh() {
stopSyncUsersStatusRefresh();
syncUsersStatusTimer = setInterval(() => {
if (currentTab === 'sync-status' && pricelistsCanWrite) {
loadUsersSyncStatus();
}
}, 30000);
}
async function loadData() {
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-gray-500">Загрузка...</div>';
@@ -750,11 +824,11 @@ function recalculateAll() {
// Show progress bar IMMEDIATELY
btn.disabled = true;
btn.textContent = 'Пересчёт...';
btn.textContent = 'Обновление...';
progressContainer.style.display = 'block';
progressBar.style.width = '0%';
progressBar.className = 'bg-blue-600 h-4 rounded-full transition-all duration-300';
progressText.textContent = 'Запуск пересчёта...';
progressText.textContent = 'Запуск обновления...';
progressPercent.textContent = '0%';
progressStats.textContent = 'Подготовка...';
@@ -769,7 +843,7 @@ function recalculateAll() {
reader.read().then(({done, value}) => {
if (done) {
btn.disabled = false;
btn.textContent = 'Пересчитать цены';
btn.textContent = 'Обновить цены';
progressText.textContent = 'Готово!';
progressBar.className = 'bg-green-600 h-4 rounded-full';
setTimeout(() => {
@@ -794,7 +868,7 @@ function recalculateAll() {
progressPercent.textContent = percent + '%';
if (data.status === 'completed') {
progressText.textContent = 'Пересчёт завершён!';
progressText.textContent = 'Обновление завершено!';
progressBar.className = 'bg-green-600 h-4 rounded-full';
} else {
progressText.textContent = data.lot_name ? 'Обработка: ' + data.lot_name : 'Обработка компонентов...';
@@ -816,7 +890,7 @@ function recalculateAll() {
console.error('Fetch error:', e);
alert('Ошибка соединения');
btn.disabled = false;
btn.textContent = 'Пересчитать цены';
btn.textContent = 'Обновить цены';
progressContainer.style.display = 'none';
});
}
@@ -861,7 +935,7 @@ function renderAllConfigs(configs) {
const date = new Date(c.created_at).toLocaleDateString('ru-RU');
const total = c.total_price ? '$' + c.total_price.toLocaleString('en-US', {minimumFractionDigits: 2}) : '—';
const serverCount = c.server_count ? c.server_count : 1;
const username = c.user ? c.user.username : '—';
const username = c.owner_username || (c.user ? c.user.username : '—');
html += '<tr class="hover:bg-gray-50">';
html += '<td class="px-3 py-2 text-sm text-gray-500">' + date + '</td>';
@@ -892,11 +966,12 @@ function renderAllConfigs(configs) {
document.getElementById('tab-content').innerHTML = html;
}
document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('DOMContentLoaded', async () => {
await checkPricelistWritePermission();
// Check URL params for initial tab
const urlParams = new URLSearchParams(window.location.search);
const initialTab = urlParams.get('tab') || 'alerts';
loadTab(initialTab);
await loadTab(initialTab);
// Add event listeners for preview updates
document.getElementById('modal-period').addEventListener('change', fetchPreview);
@@ -920,9 +995,89 @@ async function checkPricelistWritePermission() {
Создать прайслист
</button>
`;
document.getElementById('btn-sync-status').classList.remove('hidden');
if (currentTab === 'sync-status') {
await loadUsersSyncStatus();
startSyncUsersStatusRefresh();
}
} else {
document.getElementById('pricelists-create-btn-container').innerHTML = '';
document.getElementById('btn-sync-status').classList.add('hidden');
stopSyncUsersStatusRefresh();
if (currentTab === 'sync-status') {
await loadTab('alerts');
}
}
} catch (e) {
console.error('Failed to check pricelist write permission:', e);
document.getElementById('btn-sync-status').classList.add('hidden');
stopSyncUsersStatusRefresh();
}
}
function formatRelativeTime(lastSyncAt) {
const timestamp = new Date(lastSyncAt);
if (Number.isNaN(timestamp.getTime())) return '—';
const diffMinutes = Math.max(1, Math.floor((Date.now() - timestamp.getTime()) / 60000));
if (diffMinutes < 60) return `${diffMinutes} мин назад`;
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) return `${diffHours} ч назад`;
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 7) return `${diffDays} дн назад`;
const diffWeeks = Math.floor(diffDays / 7);
if (diffWeeks < 5) return `${diffWeeks} нед назад`;
const diffMonths = Math.floor(diffDays / 30);
if (diffMonths < 12) return `${diffMonths} мес назад`;
const diffYears = Math.floor(diffDays / 365);
return `${diffYears} г назад`;
}
async function loadUsersSyncStatus() {
if (!pricelistsCanWrite) return;
const body = document.getElementById('sync-users-status-body');
if (!body) return;
try {
const resp = await fetch('/api/sync/users-status');
const data = await resp.json();
if (!resp.ok) {
throw new Error(data.error || 'Ошибка загрузки');
}
const users = data.users || [];
if (users.length === 0) {
body.innerHTML = `
<tr>
<td colspan="3" class="px-6 py-4 text-sm text-gray-500">
Нет данных о синхронизации пользователей
</td>
</tr>
`;
return;
}
body.innerHTML = users.map(u => {
const statusClass = u.is_online ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-700';
const statusText = u.is_online ? 'онлайн' : formatRelativeTime(u.last_sync_at);
return `
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">${escapeHtml(u.username || '—')}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">${escapeHtml(u.app_version || '—')}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<span class="px-2 py-1 text-xs rounded-full ${statusClass}">${statusText}</span>
</td>
</tr>
`;
}).join('');
} catch (e) {
body.innerHTML = `
<tr>
<td colspan="3" class="px-6 py-4 text-sm text-red-600">
Ошибка загрузки статусов синхронизации: ${escapeHtml(e.message || String(e))}
</td>
</tr>
`;
}
}
@@ -965,6 +1120,10 @@ function renderPricelists(pricelists) {
const statusText = pl.is_active ? 'Активен' : 'Неактивен';
let actions = `<a href="/pricelists/${pl.id}" class="text-blue-600 hover:text-blue-800 text-sm">Просмотр</a>`;
if (pricelistsCanWrite) {
const toggleLabel = pl.is_active ? 'Деактивировать' : 'Активировать';
actions += ` <button onclick="togglePricelistActive(${pl.id}, ${pl.is_active ? 'false' : 'true'})" class="text-indigo-600 hover:text-indigo-800 text-sm ml-2">${toggleLabel}</button>`;
}
if (pricelistsCanWrite && pl.usage_count === 0) {
actions += ` <button onclick="deletePricelist(${pl.id})" class="text-red-600 hover:text-red-800 text-sm ml-2">Удалить</button>`;
}
@@ -989,6 +1148,33 @@ function renderPricelists(pricelists) {
document.getElementById('pricelists-body').innerHTML = html;
}
async function togglePricelistActive(id, isActive) {
// Check if online before toggling
const isOnline = await checkOnlineStatus();
if (!isOnline) {
showToast('Изменение статуса прайслиста доступно только в онлайн режиме', 'error');
return;
}
try {
const resp = await fetch(`/api/pricelists/${id}/active`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: isActive })
});
if (!resp.ok) {
const data = await resp.json();
throw new Error(data.error || 'Failed to update status');
}
showToast('Статус прайслиста обновлен', 'success');
loadPricelists(pricelistsPage);
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
}
function renderPricelistsPagination(total, page, perPage) {
const totalPages = Math.ceil(total / perPage);
if (totalPages <= 1) {
@@ -1024,6 +1210,7 @@ async function loadPricelistsDbUsername() {
function openPricelistsCreateModal() {
document.getElementById('pricelists-create-modal').classList.remove('hidden');
document.getElementById('pricelists-create-modal').classList.add('flex');
resetPricelistCreateProgress();
loadPricelistsDbUsername();
}
@@ -1049,20 +1236,88 @@ async function createPricelist() {
throw new Error('Создание прайслистов доступно только в онлайн режиме');
}
const resp = await fetch('/api/pricelists', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({})
const progressBox = document.getElementById('pricelist-create-progress');
const progressBar = document.getElementById('pricelist-create-progress-bar');
const progressText = document.getElementById('pricelist-create-progress-text');
const progressPercent = document.getElementById('pricelist-create-progress-percent');
const progressStats = document.getElementById('pricelist-create-progress-stats');
progressBox.classList.remove('hidden');
progressBar.style.width = '0%';
progressText.textContent = 'Запуск создания прайслиста...';
progressPercent.textContent = '0%';
progressStats.textContent = '';
const resp = await fetch('/api/pricelists/create-with-progress', {
method: 'POST'
});
if (!resp.ok) {
const data = await resp.json();
let data = {};
try { data = await resp.json(); } catch (_) {}
throw new Error(data.error || 'Failed to create pricelist');
}
return await resp.json();
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let completedPricelist = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value);
const lines = text.split('\n');
for (const line of lines) {
if (!line.startsWith('data:')) continue;
let data;
try {
data = JSON.parse(line.slice(5).trim());
} catch (_) {
continue;
}
const current = Number(data.current || 0);
const total = Number(data.total || 0);
const percent = total > 0 ? Math.round((current / total) * 100) : 0;
progressBar.style.width = percent + '%';
progressPercent.textContent = percent + '%';
if (data.lot_name) {
progressText.textContent = (data.message || 'Обработка') + ': ' + data.lot_name;
} else {
progressText.textContent = data.message || 'Обработка...';
}
if (data.updated !== undefined || data.errors !== undefined) {
progressStats.textContent = 'Обновлено: ' + (data.updated || 0) + ' | Ошибок: ' + (data.errors || 0);
}
if (data.status === 'error') {
throw new Error(data.message || 'Ошибка создания прайслиста');
}
if (data.status === 'completed' && data.pricelist) {
completedPricelist = data.pricelist;
}
}
}
if (!completedPricelist) {
throw new Error('Создание прервано: не получен результат');
}
return completedPricelist;
}
function resetPricelistCreateProgress() {
const progressBox = document.getElementById('pricelist-create-progress');
const progressBar = document.getElementById('pricelist-create-progress-bar');
const progressText = document.getElementById('pricelist-create-progress-text');
const progressPercent = document.getElementById('pricelist-create-progress-percent');
const progressStats = document.getElementById('pricelist-create-progress-stats');
progressBox.classList.add('hidden');
progressBar.style.width = '0%';
progressText.textContent = 'Подготовка...';
progressPercent.textContent = '0%';
progressStats.textContent = '';
}
async function deletePricelist(id) {

View File

@@ -19,7 +19,7 @@
<div class="flex items-center space-x-8">
<a href="/" class="text-xl font-bold text-blue-600">QuoteForge</a>
<div class="hidden md:flex space-x-4">
<a href="/configurator" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Конфигуратор</a>
<a href="/projects" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Мои проекты</a>
<a id="admin-pricing-link" href="/admin/pricing" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Администратор цен</a>
<a href="/setup" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Настройки</a>
</div>

View File

@@ -4,10 +4,27 @@
<div class="space-y-4">
<h1 class="text-2xl font-bold">Мои конфигурации</h1>
<div class="mt-4">
<button onclick="openCreateModal()" class="w-full 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-2 gap-3">
<button onclick="openCreateModal()" class="py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
+ Создать новую конфигурацию
</button>
<button id="import-configs-btn" onclick="importConfigsFromServer()" class="py-3 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 font-medium">
Импорт с сервера
</button>
</div>
<div class="mt-4 inline-flex rounded-lg border border-gray-200 overflow-hidden">
<button id="status-active-btn" onclick="setConfigStatusMode('active')" class="px-4 py-2 text-sm font-medium bg-blue-600 text-white">
Активные
</button>
<button id="status-archived-btn" onclick="setConfigStatusMode('archived')" class="px-4 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200">
Архив
</button>
</div>
<div class="max-w-md">
<input id="configs-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>
<div id="pricelist-badge" class="mt-4 text-sm text-gray-600 hidden">
@@ -44,6 +61,19 @@
<input type="text" id="opportunity-number" placeholder="Например: OPP-2024-001"
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 id="create-project-input"
list="create-project-options"
placeholder="Начните вводить название проекта"
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">
<button type="button" onclick="clearCreateProjectInput()" class="text-sm text-gray-600 hover:text-gray-800">
Без проекта
</button>
</div>
</div>
</div>
<div class="flex justify-end space-x-3 mt-6">
@@ -107,23 +137,84 @@
</div>
</div>
<!-- Modal for moving configuration to another project -->
<div id="move-project-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 class="text-sm text-gray-600">
Квота: <span id="move-project-config-name" class="font-medium text-gray-900"></span>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Проект</label>
<input id="move-project-input"
list="move-project-options"
placeholder="Начните вводить название проекта"
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">
<button type="button" onclick="clearMoveProjectInput()" class="text-sm text-gray-600 hover:text-gray-800">
Без проекта
</button>
</div>
<input type="hidden" id="move-project-uuid">
</div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button onclick="closeMoveProjectModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">
Отмена
</button>
<button onclick="confirmMoveProject()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
Перенести
</button>
</div>
</div>
</div>
<!-- Modal for creating project during move -->
<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>
<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>
</div>
</div>
</div>
<script>
// Pagination state
let currentPage = 1;
let totalPages = 1;
let perPage = 20;
let configStatusMode = 'active';
let configsSearch = '';
let projectsCache = [];
let projectNameByUUID = {};
let pendingMoveConfigUUID = '';
let pendingMoveProjectName = '';
let pendingCreateConfigName = '';
let pendingCreateProjectName = '';
function renderConfigs(configs) {
const emptyText = configStatusMode === 'archived'
? 'Архив пуст'
: 'Нет сохраненных конфигураций';
if (configs.length === 0) {
document.getElementById('configs-list').innerHTML =
'<div class="bg-white rounded-lg shadow p-8 text-center text-gray-500">Нет сохраненных конфигураций</div>';
'<div class="bg-white rounded-lg shadow p-8 text-center text-gray-500">' + emptyText + '</div>';
return;
}
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">Проект</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">Кол-во</th>';
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Сумма</th>';
@@ -134,6 +225,10 @@ function renderConfigs(configs) {
const date = new Date(c.created_at).toLocaleDateString('ru-RU');
const total = c.total_price ? '$' + c.total_price.toLocaleString('en-US', {minimumFractionDigits: 2}) : '—';
const serverCount = c.server_count ? c.server_count : 1;
const author = c.owner_username || (c.user && c.user.username) || '—';
const projectName = c.project_uuid && projectNameByUUID[c.project_uuid]
? projectNameByUUID[c.project_uuid]
: 'Без проекта';
// Calculate price per unit (total / server count)
let pricePerUnit = '—';
@@ -144,26 +239,57 @@ function renderConfigs(configs) {
html += '<tr class="hover:bg-gray-50">';
html += '<td class="px-4 py-3 text-sm text-gray-500">' + date + '</td>';
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>';
if (configStatusMode === 'archived') {
if (c.project_uuid) {
html += '<td class="px-4 py-3 text-sm"><a href="/projects/' + c.project_uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(projectName) + '</a></td>';
} else {
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(projectName) + '</td>';
}
} else {
if (c.project_uuid) {
html += '<td class="px-4 py-3 text-sm"><a href="/projects/' + c.project_uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(projectName) + '</a></td>';
} else {
html += '<td class="px-4 py-3 text-sm text-gray-700">' + escapeHtml(projectName) + '</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">' + pricePerUnit + '</td>';
html += '<td class="px-4 py-3 text-sm text-gray-500">' + serverCount + '</td>';
html += '<td class="px-4 py-3 text-sm text-right">' + total + '</td>';
html += '<td class="px-4 py-3 text-sm text-right space-x-2">';
html += '<button onclick="openCloneModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-green-600 hover:text-green-800" title="Копировать">';
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
html += '<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>';
html += '</svg>';
html += '</button>';
html += '<button onclick="openRenameModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-blue-600 hover:text-blue-800" title="Переименовать">';
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
html += '<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>';
html += '</svg>';
html += '</button>';
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">';
html += '<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>';
html += '</svg>';
html += '</button>';
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">';
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>';
html += '</svg>';
html += '</button>';
} else {
html += '<button onclick="openMoveProjectModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\', \'' + (c.project_uuid || '') + '\')" class="text-indigo-600 hover:text-indigo-800" title="Перенести в проект">';
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0l-3 3m3-3l3 3m7 1v12m0 0l-3-3m3 3l3-3"></path>';
html += '</svg>';
html += '</button>';
html += '<button onclick="openCloneModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-green-600 hover:text-green-800" title="Копировать">';
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
html += '<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>';
html += '</svg>';
html += '</button>';
html += '<button onclick="openRenameModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-blue-600 hover:text-blue-800" title="Переименовать">';
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
html += '<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>';
html += '</svg>';
html += '</button>';
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">';
html += '<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>';
html += '</svg>';
html += '</button>';
}
html += '</td></tr>';
});
@@ -178,13 +304,25 @@ function escapeHtml(text) {
}
async function deleteConfig(uuid) {
if (!confirm('Удалить?')) return;
if (!confirm('Переместить конфигурацию в архив?')) return;
await fetch('/api/configs/' + uuid, {
method: 'DELETE'
});
loadConfigs();
}
async function reactivateConfig(uuid) {
if (!confirm('Восстановить конфигурацию из архива?')) return;
const resp = await fetch('/api/configs/' + uuid + '/reactivate', {
method: 'POST'
});
if (!resp.ok) {
alert('Не удалось восстановить конфигурацию');
return;
}
loadConfigs();
}
function openRenameModal(uuid, currentName) {
document.getElementById('rename-uuid').value = uuid;
document.getElementById('rename-input').value = currentName;
@@ -277,6 +415,7 @@ async function cloneConfig() {
function openCreateModal() {
document.getElementById('opportunity-number').value = '';
document.getElementById('create-project-input').value = '';
document.getElementById('create-modal').classList.remove('hidden');
document.getElementById('create-modal').classList.add('flex');
document.getElementById('opportunity-number').focus();
@@ -295,6 +434,25 @@ async function createConfig() {
return;
}
const projectName = 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;
} else {
pendingCreateConfigName = name;
pendingCreateProjectName = projectName;
openCreateProjectOnCreateModal(projectName);
return;
}
}
await createConfigWithProject(name, projectUUID);
}
async function createConfigWithProject(name, projectUUID) {
try {
const resp = await fetch('/api/configs', {
method: 'POST',
@@ -305,20 +463,198 @@ async function createConfig() {
name: name,
items: [],
notes: '',
server_count: 1
server_count: 1,
project_uuid: projectUUID || null
})
});
const config = await resp.json();
if (!resp.ok) {
const err = await resp.json();
alert('Ошибка: ' + (err.error || 'Не удалось создать'));
alert('Ошибка: ' + (config.error || 'Не удалось создать'));
return false;
}
window.location.href = '/configurator?uuid=' + config.uuid;
return true;
} catch(e) {
alert('Ошибка создания конфигурации');
return false;
}
}
function openMoveProjectModal(uuid, configName, currentProjectUUID) {
document.getElementById('move-project-uuid').value = uuid;
document.getElementById('move-project-config-name').textContent = configName;
const input = document.getElementById('move-project-input');
const options = document.getElementById('move-project-options');
options.innerHTML = '';
projectsCache.forEach(project => {
if (!project.is_active) return;
const option = document.createElement('option');
option.value = project.name;
options.appendChild(option);
});
if (currentProjectUUID && projectNameByUUID[currentProjectUUID]) {
input.value = projectNameByUUID[currentProjectUUID];
} else {
input.value = '';
}
document.getElementById('move-project-modal').classList.remove('hidden');
document.getElementById('move-project-modal').classList.add('flex');
}
function closeMoveProjectModal() {
document.getElementById('move-project-modal').classList.add('hidden');
document.getElementById('move-project-modal').classList.remove('flex');
}
async function confirmMoveProject() {
const uuid = document.getElementById('move-project-uuid').value;
const projectName = 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;
} else {
pendingMoveConfigUUID = uuid;
pendingMoveProjectName = projectName;
openCreateProjectOnMoveModal(projectName);
return;
}
}
await moveConfigToProject(uuid, projectUUID);
}
function clearMoveProjectInput() {
document.getElementById('move-project-input').value = '';
}
function clearCreateProjectInput() {
document.getElementById('create-project-input').value = '';
}
function openCreateProjectOnMoveModal(projectName) {
document.getElementById('create-project-on-move-name').textContent = projectName;
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');
document.getElementById('create-project-on-move-modal').classList.add('flex');
}
function openCreateProjectOnCreateModal(projectName) {
document.getElementById('create-project-on-move-name').textContent = projectName;
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');
document.getElementById('create-project-on-move-modal').classList.add('flex');
}
function closeCreateProjectOnMoveModal() {
document.getElementById('create-project-on-move-modal').classList.add('hidden');
document.getElementById('create-project-on-move-modal').classList.remove('flex');
pendingMoveConfigUUID = '';
pendingMoveProjectName = '';
pendingCreateConfigName = '';
pendingCreateProjectName = '';
}
async function confirmCreateProjectOnMove() {
if (pendingCreateConfigName && pendingCreateProjectName) {
const configName = pendingCreateConfigName;
const projectName = pendingCreateProjectName;
try {
const createResp = await fetch('/api/projects', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ name: projectName })
});
if (!createResp.ok) {
const err = await createResp.json();
alert('Не удалось создать проект: ' + (err.error || 'ошибка'));
return;
}
const newProject = await createResp.json();
pendingCreateConfigName = '';
pendingCreateProjectName = '';
await loadProjectsForConfigUI();
const created = await createConfigWithProject(configName, newProject.uuid);
if (created) {
closeCreateProjectOnMoveModal();
} else {
closeCreateProjectOnMoveModal();
document.getElementById('create-project-input').value = projectName;
}
} catch (e) {
alert('Ошибка создания проекта');
}
return;
}
const configUUID = pendingMoveConfigUUID;
const projectName = pendingMoveProjectName;
if (!configUUID || !projectName) {
closeCreateProjectOnMoveModal();
return;
}
try {
const createResp = await fetch('/api/projects', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ name: projectName })
});
if (!createResp.ok) {
const err = await createResp.json();
alert('Не удалось создать проект: ' + (err.error || 'ошибка'));
return;
}
const config = await resp.json();
window.location.href = '/configurator?uuid=' + config.uuid;
} catch(e) {
alert('Ошибка создания конфигурации');
const newProject = await createResp.json();
pendingMoveConfigUUID = '';
pendingMoveProjectName = '';
await loadProjectsForConfigUI();
document.getElementById('move-project-input').value = projectName;
const moved = await moveConfigToProject(configUUID, newProject.uuid);
if (moved) {
closeCreateProjectOnMoveModal();
} else {
closeCreateProjectOnMoveModal();
}
} catch (e) {
alert('Ошибка создания проекта');
}
}
async function moveConfigToProject(uuid, projectUUID) {
try {
const resp = await fetch('/api/configs/' + uuid + '/project', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ project_uuid: projectUUID })
});
if (!resp.ok) {
const err = await resp.json();
alert('Не удалось перенести квоту: ' + (err.error || 'ошибка'));
return false;
}
closeMoveProjectModal();
await loadProjectsForConfigUI();
await loadConfigs();
return true;
} catch (e) {
alert('Ошибка переноса квоты');
return false;
}
}
@@ -341,12 +677,26 @@ document.getElementById('clone-modal').addEventListener('click', function(e) {
}
});
document.getElementById('move-project-modal').addEventListener('click', function(e) {
if (e.target === this) {
closeMoveProjectModal();
}
});
document.getElementById('create-project-on-move-modal').addEventListener('click', function(e) {
if (e.target === this) {
closeCreateProjectOnMoveModal();
}
});
// Close modal on Escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeCreateModal();
closeRenameModal();
closeCloneModal();
closeMoveProjectModal();
closeCreateProjectOnMoveModal();
}
});
@@ -379,18 +729,46 @@ function nextPage() {
}
function updatePagination(total) {
totalPages = Math.ceil(total / perPage);
totalPages = Math.max(1, Math.ceil(total / perPage));
document.getElementById('page-info').textContent =
'Страница ' + currentPage + ' из ' + totalPages + ' (всего: ' + total + ')';
document.getElementById('btn-prev').disabled = currentPage <= 1;
document.getElementById('btn-next').disabled = currentPage >= totalPages;
document.getElementById('pagination').classList.remove('hidden');
if (total <= perPage) {
document.getElementById('pagination').classList.add('hidden');
} else {
document.getElementById('pagination').classList.remove('hidden');
}
}
function setConfigStatusMode(mode) {
if (mode !== 'active' && mode !== 'archived') return;
configStatusMode = mode;
currentPage = 1;
applyStatusModeUI();
loadConfigs();
}
function applyStatusModeUI() {
const activeBtn = document.getElementById('status-active-btn');
const archivedBtn = document.getElementById('status-archived-btn');
const actionButtons = document.getElementById('action-buttons');
if (configStatusMode === 'archived') {
activeBtn.className = 'px-4 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50';
archivedBtn.className = 'px-4 py-2 text-sm font-medium bg-blue-600 text-white border-l border-gray-200';
actionButtons.classList.add('hidden');
} else {
activeBtn.className = 'px-4 py-2 text-sm font-medium bg-blue-600 text-white';
archivedBtn.className = 'px-4 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200';
actionButtons.classList.remove('hidden');
}
}
// Load configs with pagination
async function loadConfigs() {
try {
const resp = await fetch('/api/configs?page=' + currentPage + '&per_page=' + perPage);
const resp = await fetch('/api/configs?page=' + currentPage + '&per_page=' + perPage + '&status=' + configStatusMode + '&search=' + encodeURIComponent(configsSearch));
if (!resp.ok) {
document.getElementById('configs-list').innerHTML =
@@ -407,13 +785,80 @@ async function loadConfigs() {
}
}
async function importConfigsFromServer() {
const button = document.getElementById('import-configs-btn');
const originalText = button.textContent;
button.disabled = true;
button.textContent = 'Импорт...';
try {
const resp = await fetch('/api/configs/import', { method: 'POST' });
const data = await resp.json();
if (!resp.ok) {
alert('Ошибка импорта: ' + (data.error || 'неизвестная ошибка'));
return;
}
alert(
'Импорт завершен:\n' +
'- Новых: ' + (data.imported || 0) + '\n' +
'- Обновлено: ' + (data.updated || 0) + '\n' +
'- Пропущено (локальные изменения): ' + (data.skipped || 0)
);
currentPage = 1;
await loadConfigs();
} catch (e) {
alert('Ошибка импорта с сервера');
} finally {
button.disabled = false;
button.textContent = originalText;
}
}
document.addEventListener('DOMContentLoaded', function() {
loadConfigs();
applyStatusModeUI();
loadProjectsForConfigUI().then(loadConfigs);
// Load latest pricelist version for badge
loadLatestPricelistVersion();
});
document.getElementById('configs-search').addEventListener('input', function(e) {
configsSearch = (e.target.value || '').trim();
currentPage = 1;
loadConfigs();
});
async function loadProjectsForConfigUI() {
projectsCache = [];
projectNameByUUID = {};
try {
const resp = await fetch('/api/projects?status=all');
if (!resp.ok) return;
const data = await resp.json();
projectsCache = (data.projects || []);
projectsCache.forEach(project => {
projectNameByUUID[project.uuid] = project.name;
});
const createOptions = document.getElementById('create-project-options');
if (createOptions) {
createOptions.innerHTML = '';
projectsCache.forEach(project => {
if (!project.is_active) return;
const option = document.createElement('option');
option.value = project.name;
createOptions.appendChild(option);
});
}
} catch (e) {
// keep default behavior without project selection data
}
}
async function loadLatestPricelistVersion() {
try {
const resp = await fetch('/api/pricelists/latest');

View File

@@ -15,8 +15,14 @@
</h1>
</div>
<div id="save-buttons" class="hidden flex items-center space-x-2">
<button onclick="refreshPrices()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
Пересчитать цену
<button id="refresh-prices-btn" onclick="refreshPrices()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
Обновить цены
</button>
<button type="button"
onclick="openPriceSettingsModal()"
class="h-10 px-3 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 border border-gray-300 inline-flex items-center justify-center"
title="Настройки цен">
Цены
</button>
<button onclick="saveConfig()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">
Сохранить
@@ -34,6 +40,10 @@
class="w-20 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
onchange="updateServerCount()">
</div>
<div class="text-sm text-gray-600">
<div class="font-medium text-gray-700 mb-1">Прайслисты цен</div>
<div id="pricelist-settings-summary">Estimate: авто, Склад: авто, Конкуренты: авто</div>
</div>
<div class="text-sm text-gray-500">
<span id="server-count-info">Всего: <span id="total-server-count">1</span> сервер(а)</span>
</div>
@@ -78,20 +88,37 @@
</div>
<!-- Cart summary -->
<div id="cart-summary" class="bg-white rounded-lg shadow p-4">
<h3 class="font-semibold mb-3">Итого конфигурация</h3>
<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">
Итого: <span id="cart-total">$0.00</span>
<div id="cart-summary" class="bg-white rounded-lg shadow overflow-hidden">
<button type="button"
onclick="toggleCartSummarySection()"
class="w-full px-4 py-3 flex items-center justify-between text-blue-900 bg-gradient-to-r from-blue-100 to-blue-50 hover:from-blue-200 hover:to-blue-100 border-b border-blue-200">
<span class="font-semibold">Итого конфигурация</span>
<svg id="cart-summary-toggle-icon" class="w-5 h-5 transition-transform duration-200" 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="cart-summary-content" class="p-4">
<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">
Итого: <span id="cart-total">$0.00</span>
</div>
<button onclick="exportCSV()" class="px-3 py-1 bg-gray-200 text-gray-700 rounded text-sm hover:bg-gray-300">Экспорт CSV</button>
</div>
<button onclick="exportCSV()" class="px-3 py-1 bg-gray-200 text-gray-700 rounded text-sm hover:bg-gray-300">Экспорт CSV</button>
</div>
</div>
<!-- Custom price section -->
<div id="custom-price-section" class="bg-white rounded-lg shadow p-4">
<h3 class="font-semibold mb-3">Своя цена</h3>
<div id="custom-price-section" class="bg-white rounded-lg shadow overflow-hidden">
<button type="button"
onclick="toggleCustomPriceSection()"
class="w-full px-4 py-3 flex items-center justify-between text-blue-900 bg-gradient-to-r from-blue-100 to-blue-50 hover:from-blue-200 hover:to-blue-100 border-b border-blue-200">
<span class="font-semibold">Своя цена</span>
<svg id="custom-price-toggle-icon" class="w-5 h-5 transition-transform duration-200" 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="custom-price-content" class="p-4">
<div class="flex items-center gap-4 mb-4">
<div class="flex-1">
<label class="block text-sm text-gray-600 mb-1">Введите целевую цену</label>
@@ -147,6 +174,83 @@
</button>
</div>
</div>
</div>
</div>
<!-- Sale price section -->
<div id="sale-price-section" class="bg-white rounded-lg shadow overflow-hidden">
<button type="button"
onclick="toggleSalePriceSection()"
class="w-full px-4 py-3 flex items-center justify-between text-blue-900 bg-gradient-to-r from-blue-100 to-blue-50 hover:from-blue-200 hover:to-blue-100 border-b border-blue-200">
<span class="font-semibold">Цена продажи</span>
<svg id="sale-price-toggle-icon" class="w-5 h-5 transition-transform duration-200" 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="sale-price-content" class="p-4">
<div id="sale-prices" class="border-t pt-3">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Артикул</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Кол-во</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Est. Price</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Склад</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Конкуренты</th>
</tr>
</thead>
<tbody id="sale-prices-body" class="divide-y"></tbody>
<tfoot class="bg-gray-50 font-medium">
<tr>
<td class="px-3 py-2">Итого</td>
<td class="px-3 py-2 text-right"></td>
<td class="px-3 py-2 text-right" id="sale-total-est">$0.00</td>
<td class="px-3 py-2 text-right" id="sale-total-warehouse">$0.00</td>
<td class="px-3 py-2 text-right" id="sale-total-competitor">$0.00</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Price settings modal -->
<div id="price-settings-modal" class="hidden fixed inset-0 z-50">
<div class="absolute inset-0 bg-black/40" onclick="closePriceSettingsModal()"></div>
<div class="relative max-w-xl mx-auto mt-24 bg-white rounded-lg shadow-xl border">
<div class="px-5 py-4 border-b flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900">Настройки цен</h3>
<button type="button" onclick="closePriceSettingsModal()" class="text-gray-500 hover:text-gray-700">
<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="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="px-5 py-4 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Estimate</label>
<select id="settings-pricelist-estimate" class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"></select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Склад</label>
<select id="settings-pricelist-warehouse" class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"></select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Конкуренты</label>
<select id="settings-pricelist-competitor" class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"></select>
</div>
<label class="flex items-center gap-2 text-sm text-gray-700">
<input id="settings-disable-price-refresh" type="checkbox" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<span>Не обновлять цены</span>
</label>
</div>
<div class="px-5 py-4 border-t flex justify-end gap-2">
<button type="button" onclick="closePriceSettingsModal()" class="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200">Отмена</button>
<button type="button" onclick="applyPriceSettings()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Применить</button>
</div>
</div>
</div>
@@ -224,6 +328,18 @@ 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 selectedPricelistIds = {
estimate: null,
warehouse: null,
competitor: null
};
let disablePriceRefresh = false;
let activePricelistsBySource = {
estimate: [],
warehouse: [],
competitor: []
};
let priceLevelsRequestSeq = 0;
// Autocomplete state
let autocompleteInput = null;
@@ -232,6 +348,101 @@ let autocompleteMode = null; // 'single', 'multi', 'section'
let autocompleteIndex = -1;
let autocompleteFiltered = [];
function getDisplayPrice(item) {
if (typeof item.unit_price === 'number' && item.unit_price > 0) {
return item.unit_price;
}
if (typeof item.estimate_price === 'number' && item.estimate_price > 0) {
return item.estimate_price;
}
return 0;
}
function formatNumberRu(value) {
const rounded = Math.round(value);
return rounded
.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 0 })
.replace(/[\u202f\u00a0 ]/g, '\u00A0');
}
function formatMoney(value) {
return '$\u00A0' + formatNumberRu(value);
}
function formatPriceOrNA(value) {
if (typeof value !== 'number' || value <= 0) {
return 'N/A';
}
return formatMoney(value);
}
function formatDelta(abs, pct) {
if (typeof abs !== 'number') {
return 'N/A';
}
const sign = abs > 0 ? '+' : abs < 0 ? '-' : '';
const absValue = Math.abs(abs);
if (typeof pct !== 'number') {
return sign + formatMoney(absValue);
}
const pctSign = pct > 0 ? '+' : pct < 0 ? '-' : '';
return sign + formatMoney(absValue) + ' (' + pctSign + Math.round(Math.abs(pct)) + '%)';
}
async function refreshPriceLevels() {
if (!configUUID || cart.length === 0 || disablePriceRefresh) {
return;
}
const seq = ++priceLevelsRequestSeq;
try {
const payload = {
items: cart.map(item => ({
lot_name: item.lot_name,
quantity: item.quantity
})),
pricelist_ids: Object.fromEntries(
Object.entries(selectedPricelistIds)
.filter(([, id]) => typeof id === 'number' && id > 0)
)
};
const resp = await fetch('/api/quote/price-levels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!resp.ok) {
return;
}
const data = await resp.json();
if (seq !== priceLevelsRequestSeq) {
return;
}
const byLot = new Map((data.items || []).map(i => [i.lot_name, i]));
cart = cart.map(item => {
const levels = byLot.get(item.lot_name);
if (!levels) return item;
const next = { ...item, ...levels };
if (typeof levels.estimate_price === 'number' && levels.estimate_price > 0) {
next.unit_price = levels.estimate_price;
}
return next;
});
if (data.resolved_pricelist_ids) {
['estimate', 'warehouse', 'competitor'].forEach(source => {
if (!selectedPricelistIds[source] && data.resolved_pricelist_ids[source]) {
selectedPricelistIds[source] = data.resolved_pricelist_ids[source];
}
});
syncPriceSettingsControls();
renderPricelistSettingsSummary();
persistLocalPriceSettings();
}
} catch(e) {
console.error('Failed to refresh price levels', e);
}
}
// Load categories from API and update tab configuration
async function loadCategoriesFromAPI() {
try {
@@ -296,12 +507,16 @@ document.addEventListener('DOMContentLoaded', async function() {
serverCount = config.server_count || 1;
document.getElementById('server-count').value = serverCount;
document.getElementById('total-server-count').textContent = serverCount;
selectedPricelistIds.estimate = config.pricelist_id || null;
if (config.items && config.items.length > 0) {
cart = config.items.map(item => ({
lot_name: item.lot_name,
quantity: item.quantity,
unit_price: item.unit_price,
estimate_price: item.unit_price,
warehouse_price: null,
competitor_price: null,
description: item.description || '',
category: item.category || getCategoryFromLotName(item.lot_name)
}));
@@ -322,7 +537,13 @@ document.addEventListener('DOMContentLoaded', async function() {
return;
}
restoreLocalPriceSettings();
await loadActivePricelists();
syncPriceSettingsControls();
renderPricelistSettingsSummary();
updateRefreshPricesButtonState();
await loadAllComponents();
await refreshPriceLevels();
renderTab();
updateCartUI();
@@ -361,6 +582,151 @@ function updateServerCount() {
triggerAutoSave();
}
async function loadActivePricelists() {
const sources = ['estimate', 'warehouse', 'competitor'];
await Promise.all(sources.map(async source => {
try {
const resp = await fetch(`/api/pricelists?active_only=true&source=${source}&per_page=200`);
const data = await resp.json();
activePricelistsBySource[source] = data.pricelists || [];
const existing = selectedPricelistIds[source];
if (existing && activePricelistsBySource[source].some(pl => Number(pl.id) === Number(existing))) {
return;
}
selectedPricelistIds[source] = activePricelistsBySource[source].length > 0
? Number(activePricelistsBySource[source][0].id)
: null;
} catch (e) {
activePricelistsBySource[source] = [];
selectedPricelistIds[source] = null;
}
}));
}
function renderPricelistSelectOptions(selectId, source) {
const select = document.getElementById(selectId);
if (!select) return;
const pricelists = activePricelistsBySource[source] || [];
if (pricelists.length === 0) {
select.innerHTML = '<option value="">Нет активных прайслистов</option>';
select.value = '';
return;
}
select.innerHTML = `<option value="">Авто (последний активный)</option>` + pricelists.map(pl => {
return `<option value="${pl.id}">${escapeHtml(pl.version)}</option>`;
}).join('');
const current = selectedPricelistIds[source];
select.value = current ? String(current) : '';
}
function syncPriceSettingsControls() {
renderPricelistSelectOptions('settings-pricelist-estimate', 'estimate');
renderPricelistSelectOptions('settings-pricelist-warehouse', 'warehouse');
renderPricelistSelectOptions('settings-pricelist-competitor', 'competitor');
const disableCheckbox = document.getElementById('settings-disable-price-refresh');
if (disableCheckbox) {
disableCheckbox.checked = disablePriceRefresh;
}
}
function getPricelistVersionById(source, id) {
const pricelists = activePricelistsBySource[source] || [];
const found = pricelists.find(pl => Number(pl.id) === Number(id));
return found ? found.version : null;
}
function renderPricelistSettingsSummary() {
const summary = document.getElementById('pricelist-settings-summary');
if (!summary) return;
const estimate = selectedPricelistIds.estimate ? getPricelistVersionById('estimate', selectedPricelistIds.estimate) || `ID ${selectedPricelistIds.estimate}` : 'авто';
const warehouse = selectedPricelistIds.warehouse ? getPricelistVersionById('warehouse', selectedPricelistIds.warehouse) || `ID ${selectedPricelistIds.warehouse}` : 'авто';
const competitor = selectedPricelistIds.competitor ? getPricelistVersionById('competitor', selectedPricelistIds.competitor) || `ID ${selectedPricelistIds.competitor}` : 'авто';
const refreshState = disablePriceRefresh ? ' | Обновление цен: выкл' : '';
summary.textContent = `Estimate: ${estimate}, Склад: ${warehouse}, Конкуренты: ${competitor}${refreshState}`;
}
function updateRefreshPricesButtonState() {
const refreshBtn = document.getElementById('refresh-prices-btn');
if (!refreshBtn) return;
if (disablePriceRefresh) {
refreshBtn.disabled = true;
refreshBtn.className = 'px-4 py-2 bg-gray-300 text-gray-500 rounded cursor-not-allowed';
refreshBtn.title = 'Обновление цен отключено в настройках';
} else {
refreshBtn.disabled = false;
refreshBtn.className = 'px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700';
refreshBtn.title = '';
}
}
function getPriceSettingsStorageKey() {
return `qf_price_settings_${configUUID || 'default'}`;
}
function persistLocalPriceSettings() {
try {
localStorage.setItem(getPriceSettingsStorageKey(), JSON.stringify({
pricelist_ids: selectedPricelistIds,
disable_price_refresh: disablePriceRefresh
}));
} catch (e) {
// ignore localStorage failures
}
}
function restoreLocalPriceSettings() {
try {
const raw = localStorage.getItem(getPriceSettingsStorageKey());
if (!raw) return;
const parsed = JSON.parse(raw);
if (parsed && parsed.pricelist_ids) {
['estimate', 'warehouse', 'competitor'].forEach(source => {
const next = parseInt(parsed.pricelist_ids[source]);
if (Number.isFinite(next) && next > 0) {
selectedPricelistIds[source] = next;
}
});
}
disablePriceRefresh = Boolean(parsed?.disable_price_refresh);
} catch (e) {
// ignore invalid localStorage payload
}
}
async function openPriceSettingsModal() {
await loadActivePricelists();
syncPriceSettingsControls();
renderPricelistSettingsSummary();
document.getElementById('price-settings-modal')?.classList.remove('hidden');
}
function closePriceSettingsModal() {
document.getElementById('price-settings-modal')?.classList.add('hidden');
}
function applyPriceSettings() {
const estimateVal = parseInt(document.getElementById('settings-pricelist-estimate')?.value || '');
const warehouseVal = parseInt(document.getElementById('settings-pricelist-warehouse')?.value || '');
const competitorVal = parseInt(document.getElementById('settings-pricelist-competitor')?.value || '');
const disableVal = Boolean(document.getElementById('settings-disable-price-refresh')?.checked);
selectedPricelistIds.estimate = Number.isFinite(estimateVal) && estimateVal > 0 ? estimateVal : null;
selectedPricelistIds.warehouse = Number.isFinite(warehouseVal) && warehouseVal > 0 ? warehouseVal : null;
selectedPricelistIds.competitor = Number.isFinite(competitorVal) && competitorVal > 0 ? competitorVal : null;
disablePriceRefresh = disableVal;
updateRefreshPricesButtonState();
renderPricelistSettingsSummary();
persistLocalPriceSettings();
closePriceSettingsModal();
refreshPriceLevels().then(() => {
renderTab();
updateCartUI();
triggerAutoSave();
});
}
function getCategoryFromLotName(lotName) {
const parts = lotName.split('_');
return parts[0] || '';
@@ -433,7 +799,7 @@ function renderSingleSelectTab(categories) {
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase w-24">Тип</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">LOT</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-24">Цена</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-24">Estimate</th>
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase w-20">Кол-во</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-28">Стоимость</th>
<th class="px-3 py-2 w-10"></th>
@@ -450,8 +816,9 @@ function renderSingleSelectTab(categories) {
const comp = selectedItem ? allComponents.find(c => c.lot_name === selectedItem.lot_name) : null;
const price = comp?.current_price || 0;
const estimate = selectedItem?.estimate_price ?? price;
const qty = selectedItem?.quantity || 1;
const total = price * qty;
const total = (selectedItem ? getDisplayPrice(selectedItem) : price) * qty;
html += `
<tr class="hover:bg-gray-50">
@@ -469,14 +836,14 @@ function renderSingleSelectTab(categories) {
</div>
</td>
<td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs" id="desc-${cat}">${escapeHtml(comp?.description || '')}</td>
<td class="px-3 py-2 text-sm text-right" id="price-${cat}">${price ? '$' + price.toFixed(2) : '—'}</td>
<td class="px-3 py-2 text-sm text-right" id="price-${cat}">${formatPriceOrNA(estimate)}</td>
<td class="px-3 py-2 text-center">
<input type="number" min="1" value="${qty}"
id="qty-${cat}"
onchange="updateSingleQuantity('${cat}', this.value)"
class="w-16 px-2 py-1 border rounded text-center text-sm">
</td>
<td class="px-3 py-2 text-sm text-right font-medium" id="total-${cat}">${total ? '$' + total.toFixed(2) : '—'}</td>
<td class="px-3 py-2 text-sm text-right font-medium" id="total-${cat}">${total ? formatMoney(total) : '—'}</td>
<td class="px-3 py-2 text-center">
${selectedItem ? `
<button onclick="clearSingleSelect('${cat}')" class="text-red-500 hover:text-red-700">
@@ -508,7 +875,7 @@ function renderMultiSelectTab(components) {
<tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">LOT</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-24">Цена</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-24">Estimate</th>
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase w-20">Кол-во</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-28">Стоимость</th>
<th class="px-3 py-2 w-10"></th>
@@ -520,19 +887,19 @@ function renderMultiSelectTab(components) {
// Render existing cart items for this tab
tabItems.forEach((item, idx) => {
const comp = allComponents.find(c => c.lot_name === item.lot_name);
const total = item.unit_price * item.quantity;
const total = getDisplayPrice(item) * item.quantity;
html += `
<tr class="hover:bg-gray-50">
<td class="px-3 py-2 text-sm font-mono">${escapeHtml(item.lot_name)}</td>
<td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs">${escapeHtml(item.description || comp?.description || '')}</td>
<td class="px-3 py-2 text-sm text-right">$${item.unit_price.toFixed(2)}</td>
<td class="px-3 py-2 text-sm text-right">${formatPriceOrNA(item.estimate_price ?? item.unit_price)}</td>
<td class="px-3 py-2 text-center">
<input type="number" min="1" value="${item.quantity}"
onchange="updateMultiQuantity('${item.lot_name}', this.value)"
class="w-16 px-2 py-1 border rounded text-center text-sm">
</td>
<td class="px-3 py-2 text-sm text-right font-medium">$${total.toFixed(2)}</td>
<td class="px-3 py-2 text-sm text-right font-medium">${formatMoney(total)}</td>
<td class="px-3 py-2 text-center">
<button onclick="removeFromCart('${item.lot_name}')" class="text-red-500 hover:text-red-700">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -558,7 +925,7 @@ function renderMultiSelectTab(components) {
onkeydown="handleAutocompleteKeyMulti(event)">
</div>
</td>
<td class="px-3 py-2 text-sm text-right text-gray-400" id="new-price"></td>
<td class="px-3 py-2 text-sm text-right text-gray-400" id="new-price">N/A</td>
<td class="px-3 py-2 text-center">
<input type="number" min="1" value="1" id="new-qty"
class="w-16 px-2 py-1 border rounded text-center text-sm">
@@ -609,7 +976,7 @@ function renderMultiSelectTabWithSections(sections) {
<tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">LOT</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-24">Цена</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-24">Estimate</th>
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase w-20">Кол-во</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-28">Стоимость</th>
<th class="px-3 py-2 w-10"></th>
@@ -621,19 +988,19 @@ function renderMultiSelectTabWithSections(sections) {
// Render existing cart items for this section
sectionItems.forEach((item) => {
const comp = allComponents.find(c => c.lot_name === item.lot_name);
const total = item.unit_price * item.quantity;
const total = getDisplayPrice(item) * item.quantity;
html += `
<tr class="hover:bg-gray-50">
<td class="px-3 py-2 text-sm font-mono">${escapeHtml(item.lot_name)}</td>
<td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs">${escapeHtml(item.description || comp?.description || '')}</td>
<td class="px-3 py-2 text-sm text-right">$${item.unit_price.toFixed(2)}</td>
<td class="px-3 py-2 text-sm text-right">${formatPriceOrNA(item.estimate_price ?? item.unit_price)}</td>
<td class="px-3 py-2 text-center">
<input type="number" min="1" value="${item.quantity}"
onchange="updateMultiQuantity('${item.lot_name}', this.value)"
class="w-16 px-2 py-1 border rounded text-center text-sm">
</td>
<td class="px-3 py-2 text-sm text-right font-medium">$${total.toFixed(2)}</td>
<td class="px-3 py-2 text-sm text-right font-medium">${formatMoney(total)}</td>
<td class="px-3 py-2 text-center">
<button onclick="removeFromCart('${item.lot_name}')" class="text-red-500 hover:text-red-700">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -662,7 +1029,7 @@ function renderMultiSelectTabWithSections(sections) {
onkeydown="handleAutocompleteKeySection(event, '${sectionId}')">
</div>
</td>
<td class="px-3 py-2 text-sm text-right text-gray-400" id="new-price-${sectionId}"></td>
<td class="px-3 py-2 text-sm text-right text-gray-400" id="new-price-${sectionId}">N/A</td>
<td class="px-3 py-2 text-center">
<input type="number" min="1" value="1" id="new-qty-${sectionId}"
class="w-16 px-2 py-1 border rounded text-center text-sm">
@@ -784,14 +1151,26 @@ function selectAutocompleteItem(index) {
lot_name: comp.lot_name,
quantity: qty,
unit_price: comp.current_price,
estimate_price: comp.current_price,
warehouse_price: null,
competitor_price: null,
delta_wh_estimate_abs: null,
delta_wh_estimate_pct: null,
delta_comp_estimate_abs: null,
delta_comp_estimate_pct: null,
delta_comp_wh_abs: null,
delta_comp_wh_pct: null,
price_missing: ['warehouse', 'competitor'],
description: comp.description || '',
category: getComponentCategory(comp)
});
hideAutocomplete();
renderTab();
updateCartUI();
triggerAutoSave();
refreshPriceLevels().then(() => {
renderTab();
updateCartUI();
triggerAutoSave();
});
}
function hideAutocomplete() {
@@ -864,14 +1243,26 @@ function selectAutocompleteItemMulti(index) {
lot_name: comp.lot_name,
quantity: qty,
unit_price: comp.current_price,
estimate_price: comp.current_price,
warehouse_price: null,
competitor_price: null,
delta_wh_estimate_abs: null,
delta_wh_estimate_pct: null,
delta_comp_estimate_abs: null,
delta_comp_estimate_pct: null,
delta_comp_wh_abs: null,
delta_comp_wh_pct: null,
price_missing: ['warehouse', 'competitor'],
description: comp.description || '',
category: getComponentCategory(comp)
});
hideAutocomplete();
renderTab();
updateCartUI();
triggerAutoSave();
refreshPriceLevels().then(() => {
renderTab();
updateCartUI();
triggerAutoSave();
});
}
// Autocomplete for sectioned tabs (like storage with RAID and Disks sections)
@@ -951,6 +1342,16 @@ function selectAutocompleteItemSection(index, sectionId) {
lot_name: comp.lot_name,
quantity: qty,
unit_price: comp.current_price,
estimate_price: comp.current_price,
warehouse_price: null,
competitor_price: null,
delta_wh_estimate_abs: null,
delta_wh_estimate_pct: null,
delta_comp_estimate_abs: null,
delta_comp_estimate_pct: null,
delta_comp_wh_abs: null,
delta_comp_wh_pct: null,
price_missing: ['warehouse', 'competitor'],
description: comp.description || '',
category: getComponentCategory(comp)
});
@@ -964,9 +1365,11 @@ function selectAutocompleteItemSection(index, sectionId) {
// Reset quantity to 1
if (qtyInput) qtyInput.value = '1';
renderTab();
updateCartUI();
triggerAutoSave();
refreshPriceLevels().then(() => {
renderTab();
updateCartUI();
triggerAutoSave();
});
}
function clearSingleSelect(category) {
@@ -1005,7 +1408,7 @@ function updateMultiQuantity(lotName, value) {
if (row) {
const totalCell = row.querySelector('td:nth-child(5)');
if (totalCell) {
totalCell.textContent = '$' + (item.unit_price * item.quantity).toFixed(2);
totalCell.textContent = formatMoney(getDisplayPrice(item) * item.quantity);
}
}
}
@@ -1019,11 +1422,12 @@ function removeFromCart(lotName) {
}
function updateCartUI() {
const total = cart.reduce((sum, item) => sum + (item.unit_price * item.quantity), 0);
document.getElementById('cart-total').textContent = '$' + total.toLocaleString('en-US', {minimumFractionDigits: 2});
const total = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0);
document.getElementById('cart-total').textContent = formatMoney(total);
// Recalculate custom price section if active
calculateCustomPrice();
renderSalePriceTable();
if (cart.length === 0) {
document.getElementById('cart-items').innerHTML =
@@ -1067,7 +1471,7 @@ function updateCartUI() {
html += `<div class="mb-2"><div class="text-xs font-medium text-gray-400 uppercase mb-1">${tabLabel}</div>`;
items.forEach(item => {
const itemTotal = item.unit_price * item.quantity;
const itemTotal = getDisplayPrice(item) * item.quantity;
html += `
<div class="flex justify-between items-center py-1 text-sm">
<div class="flex-1">
@@ -1076,7 +1480,7 @@ function updateCartUI() {
<span>${item.quantity}</span>
</div>
<div class="flex items-center space-x-2">
<span>$${itemTotal.toLocaleString('en-US', {minimumFractionDigits: 2})}</span>
<span>${formatMoney(itemTotal)}</span>
<button onclick="removeFromCart('${item.lot_name}')" class="text-red-500 hover:text-red-700">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
@@ -1133,7 +1537,8 @@ async function saveConfig(showNotification = true) {
items: cart,
custom_price: customPrice,
notes: '',
server_count: serverCountValue
server_count: serverCountValue,
pricelist_id: selectedPricelistIds.estimate
})
});
@@ -1176,12 +1581,143 @@ async function exportCSV() {
}
}
function formatLineTotalTooltip(qty, unitPrice) {
if (typeof unitPrice !== 'number' || unitPrice <= 0) return '';
const lineTotal = qty * unitPrice;
return `${formatNumberRu(qty)} * ${formatMoney(unitPrice)} = ${formatMoney(lineTotal)}`;
}
function toggleSection(contentId, iconId) {
const content = document.getElementById(contentId);
const icon = document.getElementById(iconId);
if (!content || !icon) return;
const isHidden = content.classList.toggle('hidden');
if (isHidden) {
icon.classList.add('-rotate-90');
} else {
icon.classList.remove('-rotate-90');
}
}
function toggleCartSummarySection() {
toggleSection('cart-summary-content', 'cart-summary-toggle-icon');
}
function toggleCustomPriceSection() {
toggleSection('custom-price-content', 'custom-price-toggle-icon');
}
function toggleSalePriceSection() {
toggleSection('sale-price-content', 'sale-price-toggle-icon');
}
function formatDiffPercent(baseTotal, compareTotal, compareLabel) {
if (typeof baseTotal !== 'number' || typeof compareTotal !== 'number' || compareTotal <= 0) {
return `N/A от ${compareLabel}`;
}
const pct = ((baseTotal - compareTotal) / compareTotal) * 100;
const sign = pct > 0 ? '+' : '';
return `${sign}${pct.toFixed(1)}% от ${compareLabel}`;
}
function getTotalClass(current, references) {
const validRefs = references.filter(v => typeof v === 'number' && v > 0);
if (typeof current !== 'number' || current <= 0 || validRefs.length === 0) {
return 'text-gray-900';
}
const avg = validRefs.reduce((sum, v) => sum + v, 0) / validRefs.length;
if (current < avg) return 'text-green-600';
if (current > avg) return 'text-red-600';
return 'text-gray-900';
}
function renderSalePriceTable() {
const body = document.getElementById('sale-prices-body');
const totalEstEl = document.getElementById('sale-total-est');
const totalWarehouseEl = document.getElementById('sale-total-warehouse');
const totalCompetitorEl = document.getElementById('sale-total-competitor');
if (!body || !totalEstEl || !totalWarehouseEl || !totalCompetitorEl) return;
if (cart.length === 0) {
body.innerHTML = '<tr><td colspan="5" class="px-3 py-3 text-center text-gray-500">Конфигурация пуста</td></tr>';
totalEstEl.textContent = '$0.00';
totalWarehouseEl.textContent = '$0.00';
totalCompetitorEl.textContent = '$0.00';
totalEstEl.title = '';
totalWarehouseEl.title = '';
totalCompetitorEl.title = '';
totalEstEl.className = 'px-3 py-2 text-right';
totalWarehouseEl.className = 'px-3 py-2 text-right';
totalCompetitorEl.className = 'px-3 py-2 text-right';
return;
}
const sortedCart = [...cart].sort((a, b) => {
const catA = (a.category || getCategoryFromLotName(a.lot_name)).toUpperCase();
const catB = (b.category || getCategoryFromLotName(b.lot_name)).toUpperCase();
const orderA = categoryOrderMap[catA] || 9999;
const orderB = categoryOrderMap[catB] || 9999;
return orderA - orderB;
});
let html = '';
let totalEstimate = 0;
let totalWarehouse = 0;
let totalCompetitor = 0;
sortedCart.forEach(item => {
const qty = item.quantity || 1;
const estPrice = item.estimate_price;
const warehousePrice = item.warehouse_price;
const competitorPrice = item.competitor_price;
if (typeof estPrice === 'number' && estPrice > 0) totalEstimate += estPrice * qty;
if (typeof warehousePrice === 'number' && warehousePrice > 0) totalWarehouse += warehousePrice * qty;
if (typeof competitorPrice === 'number' && competitorPrice > 0) totalCompetitor += competitorPrice * qty;
const estTooltip = formatLineTotalTooltip(qty, estPrice);
const warehouseTooltip = formatLineTotalTooltip(qty, warehousePrice);
const competitorTooltip = formatLineTotalTooltip(qty, competitorPrice);
html += `
<tr>
<td class="px-3 py-2 font-mono">${escapeHtml(item.lot_name)}</td>
<td class="px-3 py-2 text-right">${qty}</td>
<td class="px-3 py-2 text-right" title="${escapeHtml(estTooltip)}">${formatPriceOrNA(estPrice)}</td>
<td class="px-3 py-2 text-right" title="${escapeHtml(warehouseTooltip)}">${formatPriceOrNA(warehousePrice)}</td>
<td class="px-3 py-2 text-right" title="${escapeHtml(competitorTooltip)}">${formatPriceOrNA(competitorPrice)}</td>
</tr>
`;
});
body.innerHTML = html;
totalEstEl.textContent = formatMoney(totalEstimate);
totalWarehouseEl.textContent = formatMoney(totalWarehouse);
totalCompetitorEl.textContent = formatMoney(totalCompetitor);
totalEstEl.title =
`${formatDiffPercent(totalEstimate, totalWarehouse, 'склад')}\n` +
`${formatDiffPercent(totalEstimate, totalCompetitor, 'конкуренты')}`;
totalWarehouseEl.title =
`${formatDiffPercent(totalWarehouse, totalEstimate, 'est.price')}\n` +
`${formatDiffPercent(totalWarehouse, totalCompetitor, 'конкуренты')}`;
totalCompetitorEl.title =
`${formatDiffPercent(totalCompetitor, totalEstimate, 'est.price')}\n` +
`${formatDiffPercent(totalCompetitor, totalWarehouse, 'склад')}`;
totalEstEl.className = `px-3 py-2 text-right ${getTotalClass(totalEstimate, [totalWarehouse, totalCompetitor])}`;
totalWarehouseEl.className = `px-3 py-2 text-right ${getTotalClass(totalWarehouse, [totalEstimate, totalCompetitor])}`;
totalCompetitorEl.className = `px-3 py-2 text-right ${getTotalClass(totalCompetitor, [totalEstimate, totalWarehouse])}`;
}
// Custom price functionality
function calculateCustomPrice() {
const customPriceInput = document.getElementById('custom-price-input');
const customPrice = parseFloat(customPriceInput.value) || 0;
const originalTotal = cart.reduce((sum, item) => sum + (item.unit_price * item.quantity), 0);
const originalTotal = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0);
if (customPrice <= 0 || cart.length === 0 || originalTotal <= 0) {
document.getElementById('adjusted-prices').classList.add('hidden');
@@ -1222,7 +1758,7 @@ function calculateCustomPrice() {
let totalNew = 0;
sortedCart.forEach(item => {
const originalPrice = item.unit_price;
const originalPrice = getDisplayPrice(item);
const newPrice = originalPrice * coefficient;
const itemOriginalTotal = originalPrice * item.quantity;
const itemNewTotal = newPrice * item.quantity;
@@ -1234,17 +1770,17 @@ function calculateCustomPrice() {
<tr>
<td class="px-3 py-2 font-mono">${escapeHtml(item.lot_name)}</td>
<td class="px-3 py-2 text-right">${item.quantity}</td>
<td class="px-3 py-2 text-right text-gray-500">$${originalPrice.toFixed(2)}</td>
<td class="px-3 py-2 text-right text-green-600">$${newPrice.toFixed(2)}</td>
<td class="px-3 py-2 text-right">$${itemNewTotal.toFixed(2)}</td>
<td class="px-3 py-2 text-right text-gray-500">${formatMoney(originalPrice)}</td>
<td class="px-3 py-2 text-right text-green-600">${formatMoney(newPrice)}</td>
<td class="px-3 py-2 text-right">${formatMoney(itemNewTotal)}</td>
</tr>
`;
});
document.getElementById('adjusted-prices-body').innerHTML = html;
document.getElementById('adjusted-total-original').textContent = '$' + totalOriginal.toFixed(2);
document.getElementById('adjusted-total-new').textContent = '$' + totalNew.toFixed(2);
document.getElementById('adjusted-total-final').textContent = '$' + totalNew.toFixed(2);
document.getElementById('adjusted-total-original').textContent = formatMoney(totalOriginal);
document.getElementById('adjusted-total-new').textContent = formatMoney(totalNew);
document.getElementById('adjusted-total-final').textContent = formatMoney(totalNew);
document.getElementById('adjusted-prices').classList.remove('hidden');
}
@@ -1259,7 +1795,7 @@ async function exportCSVWithCustomPrice() {
if (cart.length === 0) return;
const customPrice = parseFloat(document.getElementById('custom-price-input').value) || 0;
const originalTotal = cart.reduce((sum, item) => sum + (item.unit_price * item.quantity), 0);
const originalTotal = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0);
if (customPrice <= 0 || originalTotal <= 0) {
showToast('Введите целевую цену', 'error');
@@ -1271,7 +1807,7 @@ async function exportCSVWithCustomPrice() {
// Create adjusted cart
const adjustedCart = cart.map(item => ({
...item,
unit_price: parseFloat((item.unit_price * coefficient).toFixed(2))
unit_price: parseFloat((getDisplayPrice(item) * coefficient).toFixed(2))
}));
try {
@@ -1296,6 +1832,10 @@ async function exportCSVWithCustomPrice() {
async function refreshPrices() {
// RBAC disabled - no token check required
if (!configUUID) return;
if (disablePriceRefresh) {
showToast('Обновление цен отключено в настройках', 'error');
return;
}
try {
const resp = await fetch('/api/configs/' + configUUID + '/refresh-prices', {
@@ -1318,6 +1858,9 @@ async function refreshPrices() {
lot_name: item.lot_name,
quantity: item.quantity,
unit_price: item.unit_price,
estimate_price: item.unit_price,
warehouse_price: null,
competitor_price: null,
description: item.description || '',
category: item.category || getCategoryFromLotName(item.lot_name)
}));
@@ -1327,8 +1870,18 @@ async function refreshPrices() {
if (config.price_updated_at) {
updatePriceUpdateDate(config.price_updated_at);
}
if (config.pricelist_id) {
selectedPricelistIds.estimate = config.pricelist_id;
if (!activePricelistsBySource.estimate.some(opt => Number(opt.id) === Number(config.pricelist_id))) {
await loadActivePricelists();
}
syncPriceSettingsControls();
renderPricelistSettingsSummary();
persistLocalPriceSettings();
}
// Re-render UI
await refreshPriceLevels();
renderTab();
updateCartUI();

View File

@@ -147,12 +147,15 @@
let settings = [];
const hasManualPrice = item.manual_price && item.manual_price > 0;
const hasMeta = item.meta_prices && item.meta_prices.trim() !== '';
const method = (item.price_method || '').toLowerCase();
// Method indicator
if (hasManualPrice) {
settings.push('<span class="text-orange-600 font-medium">РУЧН</span>');
} else if (item.price_method === 'average') {
} else if (method === 'average') {
settings.push('Сред');
} else if (method === 'weighted_median') {
settings.push('Взвеш. мед');
} else {
settings.push('Мед');
}

View File

@@ -0,0 +1,501 @@
{{define "title"}}Проект - QuoteForge{{end}}
{{define "content"}}
<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="Назад к проектам">
<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>
</svg>
</a>
<h1 class="text-2xl font-bold" id="project-title">Проект</h1>
</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">
+ Создать новую квоту
</button>
<button onclick="openImportModal()" class="py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 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">
открыть в трекере
</a>
</div>
<div class="mt-4 inline-flex rounded-lg border border-gray-200 overflow-hidden">
<button id="status-active-btn" onclick="setConfigStatusMode('active')" class="px-4 py-2 text-sm font-medium bg-blue-600 text-white">
Активные
</button>
<button id="status-archived-btn" onclick="setConfigStatusMode('archived')" class="px-4 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200">
Архив
</button>
</div>
<div id="configs-list">
<div class="text-center py-8 text-gray-500">Загрузка...</div>
</div>
</div>
<div id="create-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="create-name" placeholder="Например: OPP-2026-001"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button onclick="closeCreateModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
<button onclick="createConfig()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-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>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Новое название</label>
<input type="text" id="rename-input"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<input type="hidden" id="rename-uuid">
</div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button onclick="closeRenameModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
<button onclick="renameConfig()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Сохранить</button>
</div>
</div>
</div>
<div id="clone-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="clone-input"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<input type="hidden" id="clone-uuid">
</div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button onclick="closeCloneModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
<button onclick="cloneConfig()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">Копировать</button>
</div>
</div>
</div>
<div id="import-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-3">
<label class="block text-sm font-medium text-gray-700">Квота</label>
<input id="import-config-input"
list="import-config-options"
placeholder="Начните вводить название квоты"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<datalist id="import-config-options"></datalist>
<div class="text-xs text-gray-500">Квота будет перемещена в текущий проект.</div>
</div>
<div class="flex justify-end gap-2 mt-6">
<button onclick="closeImportModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
<button onclick="importConfigToProject()" class="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700">Импортировать</button>
</div>
</div>
</div>
<script>
const projectUUID = '{{.ProjectUUID}}';
let configStatusMode = 'active';
let project = null;
let allConfigs = [];
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
}
function resolveProjectTrackerURL(projectData) {
if (!projectData) return '';
const explicitURL = (projectData.tracker_url || '').trim();
return explicitURL;
}
function setConfigStatusMode(mode) {
if (mode !== 'active' && mode !== 'archived') return;
configStatusMode = mode;
applyStatusModeUI();
loadConfigs();
}
function applyStatusModeUI() {
const activeBtn = document.getElementById('status-active-btn');
const archivedBtn = document.getElementById('status-archived-btn');
const actionButtons = document.getElementById('action-buttons');
if (configStatusMode === 'archived') {
activeBtn.className = 'px-4 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50';
archivedBtn.className = 'px-4 py-2 text-sm font-medium bg-blue-600 text-white border-l border-gray-200';
actionButtons.classList.add('hidden');
} else {
activeBtn.className = 'px-4 py-2 text-sm font-medium bg-blue-600 text-white';
archivedBtn.className = 'px-4 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200';
actionButtons.classList.remove('hidden');
}
}
function renderConfigs(configs) {
const emptyText = configStatusMode === 'archived' ? 'Архив пуст' : 'Нет квот в проекте';
if (configs.length === 0) {
document.getElementById('configs-list').innerHTML =
'<div class="bg-white rounded-lg shadow p-8 text-center text-gray-500">' + emptyText + '</div>';
return;
}
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">Название</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">Кол-во</th>';
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Сумма</th>';
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
html += '</tr></thead><tbody class="divide-y">';
configs.forEach(c => {
const date = new Date(c.created_at).toLocaleDateString('ru-RU');
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;
totalSum += total;
html += '<tr class="hover:bg-gray-50">';
html += '<td class="px-4 py-3 text-sm text-gray-500">' + date + '</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">' + serverCount + '</td>';
html += '<td class="px-4 py-3 text-sm text-right">$' + total.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
html += '<td class="px-4 py-3 text-sm text-right space-x-2">';
if (configStatusMode === 'archived') {
html += '<button onclick="reactivateConfig(\'' + c.uuid + '\')" class="text-emerald-600 hover:text-emerald-800" title="Восстановить">';
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>';
} else {
html += '<button onclick="openCloneModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-green-600 hover:text-green-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="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></button>';
html += '<button onclick="openRenameModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-blue-600 hover:text-blue-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="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></button>';
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 += '</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 text-gray-700">' + configs.length + '</td>';
html += '<td class="px-4 py-3 text-sm text-right font-semibold text-gray-900">$' + totalSum.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
html += '<td class="px-4 py-3"></td>';
html += '</tr>';
html += '</tfoot>';
html += '</table></div>';
document.getElementById('configs-list').innerHTML = html;
}
async function loadProject() {
const resp = await fetch('/api/projects/' + projectUUID);
if (!resp.ok) {
document.getElementById('configs-list').innerHTML = '<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Проект не найден</div>';
return false;
}
project = await resp.json();
document.getElementById('project-title').textContent = project.name;
const trackerLink = document.getElementById('tracker-link');
if (trackerLink) {
if (project && project.is_system) {
trackerLink.classList.add('hidden');
return true;
}
const trackerURL = resolveProjectTrackerURL(project);
if (trackerURL) {
trackerLink.href = trackerURL;
trackerLink.classList.remove('hidden');
} else {
trackerLink.classList.add('hidden');
}
}
return true;
}
async function loadConfigs() {
try {
const resp = await fetch('/api/projects/' + projectUUID + '/configs?status=' + configStatusMode);
if (!resp.ok) {
document.getElementById('configs-list').innerHTML =
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки</div>';
return;
}
const data = await resp.json();
allConfigs = (data.configurations || []);
renderConfigs(allConfigs);
} catch (e) {
document.getElementById('configs-list').innerHTML =
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки</div>';
}
}
function openCreateModal() {
document.getElementById('create-name').value = '';
document.getElementById('create-modal').classList.remove('hidden');
document.getElementById('create-modal').classList.add('flex');
document.getElementById('create-name').focus();
}
function closeCreateModal() {
document.getElementById('create-modal').classList.add('hidden');
document.getElementById('create-modal').classList.remove('flex');
}
async function createConfig() {
const name = document.getElementById('create-name').value.trim();
if (!name) {
alert('Введите название');
return;
}
const resp = await fetch('/api/projects/' + projectUUID + '/configs', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: name, items: [], notes: '', server_count: 1})
});
if (!resp.ok) {
alert('Не удалось создать квоту');
return;
}
closeCreateModal();
loadConfigs();
}
async function deleteConfig(uuid) {
if (!confirm('Переместить квоту в архив?')) return;
await fetch('/api/configs/' + uuid, {method: 'DELETE'});
loadConfigs();
}
async function reactivateConfig(uuid) {
const resp = await fetch('/api/configs/' + uuid + '/reactivate', {method: 'POST'});
if (!resp.ok) {
alert('Не удалось восстановить квоту');
return;
}
const moved = await fetch('/api/configs/' + uuid + '/project', {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({project_uuid: projectUUID})
});
if (!moved.ok) {
alert('Квота восстановлена, но не удалось вернуть в проект');
}
loadConfigs();
}
function openRenameModal(uuid, currentName) {
document.getElementById('rename-uuid').value = uuid;
document.getElementById('rename-input').value = currentName;
document.getElementById('rename-modal').classList.remove('hidden');
document.getElementById('rename-modal').classList.add('flex');
}
function closeRenameModal() {
document.getElementById('rename-modal').classList.add('hidden');
document.getElementById('rename-modal').classList.remove('flex');
}
async function renameConfig() {
const uuid = document.getElementById('rename-uuid').value;
const name = document.getElementById('rename-input').value.trim();
if (!name) {
alert('Введите название');
return;
}
const resp = await fetch('/api/configs/' + uuid + '/rename', {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: name})
});
if (!resp.ok) {
alert('Не удалось переименовать');
return;
}
closeRenameModal();
loadConfigs();
}
function openCloneModal(uuid, currentName) {
document.getElementById('clone-uuid').value = uuid;
document.getElementById('clone-input').value = currentName + ' (копия)';
document.getElementById('clone-modal').classList.remove('hidden');
document.getElementById('clone-modal').classList.add('flex');
}
function closeCloneModal() {
document.getElementById('clone-modal').classList.add('hidden');
document.getElementById('clone-modal').classList.remove('flex');
}
async function cloneConfig() {
const uuid = document.getElementById('clone-uuid').value;
const name = document.getElementById('clone-input').value.trim();
if (!name) {
alert('Введите название');
return;
}
const resp = await fetch('/api/projects/' + projectUUID + '/configs/' + uuid + '/clone', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: name})
});
if (!resp.ok) {
alert('Не удалось скопировать');
return;
}
closeCloneModal();
loadConfigs();
}
function openImportModal() {
const activeOther = allConfigs.length ? null : null; // no-op placeholder
void activeOther;
document.getElementById('import-config-input').value = '';
document.getElementById('import-config-options').innerHTML = '';
loadImportOptions();
document.getElementById('import-modal').classList.remove('hidden');
document.getElementById('import-modal').classList.add('flex');
}
function closeImportModal() {
document.getElementById('import-modal').classList.add('hidden');
document.getElementById('import-modal').classList.remove('flex');
}
async function loadImportOptions() {
const resp = await fetch('/api/configs?page=1&per_page=500&status=active');
if (!resp.ok) return;
const data = await resp.json();
const options = document.getElementById('import-config-options');
options.innerHTML = '';
(data.configurations || [])
.filter(c => c.project_uuid !== projectUUID)
.forEach(c => {
const opt = document.createElement('option');
opt.value = c.name;
options.appendChild(opt);
});
}
async function importConfigToProject() {
const query = document.getElementById('import-config-input').value.trim();
if (!query) {
alert('Выберите квоту');
return;
}
const resp = await fetch('/api/configs?page=1&per_page=500&status=active');
if (!resp.ok) {
alert('Не удалось загрузить список квот');
return;
}
const data = await resp.json();
const sourceConfigs = (data.configurations || []).filter(c => c.project_uuid !== projectUUID);
let targets = [];
if (query.includes('*')) {
targets = sourceConfigs.filter(c => wildcardMatch(c.name || '', query));
} else {
const found = sourceConfigs.find(c => (c.name || '').toLowerCase() === query.toLowerCase());
if (found) {
targets = [found];
}
}
if (!targets.length) {
alert('Подходящие квоты не найдены');
return;
}
let moved = 0;
let failed = 0;
for (const cfg of targets) {
const move = await fetch('/api/configs/' + cfg.uuid + '/project', {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({project_uuid: projectUUID})
});
if (move.ok) {
moved++;
} else {
failed++;
}
}
if (!moved) {
alert('Не удалось импортировать квоты');
return;
}
closeImportModal();
await loadConfigs();
if (targets.length > 1 || failed > 0) {
alert('Импорт завершен: перенесено ' + moved + ', ошибок ' + failed);
}
}
function wildcardMatch(value, pattern) {
const escaped = pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '.*');
const regex = new RegExp('^' + escaped + '$', 'i');
return regex.test(value);
}
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('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.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeCreateModal();
closeRenameModal();
closeCloneModal();
closeImportModal();
}
});
document.addEventListener('DOMContentLoaded', async function() {
applyStatusModeUI();
const ok = await loadProject();
if (!ok) return;
await loadConfigs();
});
</script>
{{end}}
{{template "base" .}}

426
web/templates/projects.html Normal file
View File

@@ -0,0 +1,426 @@
{{define "title"}}Мои проекты - QuoteForge{{end}}
{{define "content"}}
<div class="space-y-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<h1 class="text-2xl font-bold">Мои проекты</h1>
<div class="flex items-center gap-2">
<a href="/configs" class="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300">
Все конфигурации
</a>
<button onclick="openCreateProjectModal()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
+ Новый проект
</button>
</div>
</div>
<div class="inline-flex rounded-lg border border-gray-200 overflow-hidden">
<button id="status-active-btn" onclick="setStatus('active')" class="px-4 py-2 text-sm font-medium bg-blue-600 text-white">Активные</button>
<button id="status-archived-btn" onclick="setStatus('archived')" class="px-4 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200">Архив</button>
</div>
<div class="max-w-md">
<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>
<div id="projects-table" class="bg-white rounded-lg shadow p-4 text-gray-500">Загрузка...</div>
</div>
<div id="create-project-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 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-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"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
</div>
<div class="flex justify-end gap-2 mt-6">
<button type="button" onclick="closeCreateProjectModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
<button type="button" onclick="createProject()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Создать</button>
</div>
</div>
</div>
<script>
let status = 'active';
let projectsSearch = '';
let authorSearch = '';
let currentPage = 1;
let perPage = 10;
let sortField = 'created_at';
let sortDir = 'desc';
let createProjectTrackerManuallyEdited = false;
let createProjectLastAutoTrackerURL = '';
const trackerBaseURL = 'https://tracker.yandex.ru/';
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
}
function formatMoney(v) {
return '$' + (v || 0).toLocaleString('en-US', {minimumFractionDigits: 2});
}
function formatDateTime(value) {
if (!value) return '—';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '—';
return date.toLocaleString('ru-RU', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
function toggleSort(field) {
if (sortField === field) {
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
} else {
sortField = field;
sortDir = field === 'name' ? 'asc' : 'desc';
}
currentPage = 1;
loadProjects();
}
function setStatus(value) {
status = value;
currentPage = 1;
document.getElementById('status-active-btn').className = value === 'active'
? 'px-4 py-2 text-sm font-medium bg-blue-600 text-white'
: 'px-4 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50';
document.getElementById('status-archived-btn').className = value === 'archived'
? 'px-4 py-2 text-sm font-medium bg-blue-600 text-white border-l border-gray-200'
: 'px-4 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200';
loadProjects();
}
async function loadProjects() {
const root = document.getElementById('projects-table');
root.innerHTML = '<div class="text-gray-500">Загрузка...</div>';
let rows = [];
let total = 0;
let totalPages = 0;
let page = currentPage;
try {
const params = new URLSearchParams({
status: status,
search: projectsSearch,
author: authorSearch,
page: String(currentPage),
per_page: String(perPage),
sort: sortField,
dir: sortDir
});
const resp = await fetch('/api/projects?' + params.toString());
if (!resp.ok) {
throw new Error('HTTP ' + resp.status);
}
const data = await resp.json();
rows = data.projects || [];
total = data.total || 0;
totalPages = data.total_pages || 0;
page = data.page || currentPage;
currentPage = page;
} catch (e) {
root.innerHTML = '<div class="text-red-600">Ошибка загрузки проектов: ' + escapeHtml(String(e.message || e)) + '</div>';
return;
}
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">';
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-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"></th>';
html += '<th class="px-4 py-2"></th>';
html += '<th class="px-4 py-2"></th>';
html += '</tr>';
html += '</thead><tbody class="divide-y">';
if (!rows.length) {
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>';
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="Копировать">';
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 += '<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>';
html += '<button onclick="archiveProject(\'' + p.uuid + '\')" class="text-red-700 hover:text-red-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="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>';
html += '</button>';
html += '<button onclick="addConfigToProject(\'' + p.uuid + '\')" class="text-indigo-700 hover:text-indigo-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="M12 4v16m8-8H4"></path></svg>';
html += '</button>';
} else {
html += '<button onclick="reactivateProject(\'' + p.uuid + '\')" class="text-emerald-700 hover:text-emerald-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="M5 13l4 4L19 7"></path></svg>';
html += '</button>';
}
html += '</div></td>';
html += '</tr>';
});
html += '</tbody></table></div>';
if (totalPages > 1) {
html += '<div class="flex items-center justify-between mt-4 pt-4 border-t">';
html += '<div class="text-sm text-gray-600">Показано ' + rows.length + ' из ' + total + '</div>';
html += '<div class="inline-flex items-center gap-1">';
html += '<button type="button" onclick="goToPage(' + (page - 1) + ')" ' + (page <= 1 ? 'disabled' : '') + ' class="px-3 py-1 text-sm border rounded ' + (page <= 1 ? 'text-gray-300 border-gray-200 cursor-not-allowed' : 'text-gray-700 hover:bg-gray-50') + '">&larr;</button>';
const startPage = Math.max(1, page - 2);
const endPage = Math.min(totalPages, page + 2);
for (let i = startPage; i <= endPage; i++) {
html += '<button type="button" onclick="goToPage(' + i + ')" class="px-3 py-1 text-sm border rounded ' + (i === page ? 'bg-blue-600 text-white border-blue-600' : 'text-gray-700 border-gray-300 hover:bg-gray-50') + '">' + i + '</button>';
}
html += '<button type="button" onclick="goToPage(' + (page + 1) + ')" ' + (page >= totalPages ? 'disabled' : '') + ' class="px-3 py-1 text-sm border rounded ' + (page >= totalPages ? 'text-gray-300 border-gray-200 cursor-not-allowed' : 'text-gray-700 hover:bg-gray-50') + '">&rarr;</button>';
html += '</div>';
html += '</div>';
}
root.innerHTML = html;
const authorInput = document.getElementById('projects-author-filter');
if (authorInput) {
authorInput.addEventListener('input', function(e) {
authorSearch = (e.target.value || '').trim();
currentPage = 1;
loadProjects();
});
}
}
function goToPage(page) {
if (page < 1) return;
currentPage = page;
loadProjects();
}
function buildTrackerURLFromProjectCode(projectCode) {
const code = (projectCode || '').trim();
if (!code) return '';
return trackerBaseURL + encodeURIComponent(code);
}
function openCreateProjectModal() {
const codeInput = document.getElementById('create-project-code');
const trackerInput = document.getElementById('create-project-tracker-url');
codeInput.value = '';
trackerInput.value = '';
createProjectTrackerManuallyEdited = false;
createProjectLastAutoTrackerURL = '';
document.getElementById('create-project-modal').classList.remove('hidden');
document.getElementById('create-project-modal').classList.add('flex');
codeInput.focus();
}
function closeCreateProjectModal() {
document.getElementById('create-project-modal').classList.add('hidden');
document.getElementById('create-project-modal').classList.remove('flex');
}
function updateCreateProjectTrackerURL() {
const codeInput = document.getElementById('create-project-code');
const trackerInput = document.getElementById('create-project-tracker-url');
const generatedURL = buildTrackerURLFromProjectCode(codeInput.value);
if (!createProjectTrackerManuallyEdited || trackerInput.value.trim() === '' || trackerInput.value === createProjectLastAutoTrackerURL) {
trackerInput.value = generatedURL;
createProjectLastAutoTrackerURL = generatedURL;
}
}
async function createProject() {
const codeInput = document.getElementById('create-project-code');
const trackerInput = document.getElementById('create-project-tracker-url');
const name = (codeInput.value || '').trim();
if (!name) {
alert('Введите код проекта');
return;
}
const resp = await fetch('/api/projects', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
name: name,
tracker_url: (trackerInput.value || '').trim()
})
});
if (!resp.ok) {
alert('Не удалось создать проект');
return;
}
closeCreateProjectModal();
loadProjects();
}
async function renameProject(projectUUID, currentName) {
const name = prompt('Новое название проекта', currentName);
if (!name || !name.trim() || name.trim() === currentName) return;
const resp = await fetch('/api/projects/' + projectUUID, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: name.trim()})
});
if (!resp.ok) {
alert('Не удалось переименовать проект');
return;
}
loadProjects();
}
async function archiveProject(projectUUID) {
if (!confirm('Переместить проект в архив?')) return;
const resp = await fetch('/api/projects/' + projectUUID + '/archive', {method: 'POST'});
if (!resp.ok) {
alert('Не удалось архивировать проект');
return;
}
loadProjects();
}
async function reactivateProject(projectUUID) {
const resp = await fetch('/api/projects/' + projectUUID + '/reactivate', {method: 'POST'});
if (!resp.ok) {
alert('Не удалось восстановить проект');
return;
}
loadProjects();
}
async function addConfigToProject(projectUUID) {
const name = prompt('Название новой квоты');
if (!name || !name.trim()) return;
const resp = await fetch('/api/projects/' + projectUUID + '/configs', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: name.trim(), items: [], notes: '', server_count: 1})
});
if (!resp.ok) {
alert('Не удалось создать квоту');
return;
}
loadProjects();
}
async function copyProject(projectUUID, projectName) {
const newName = prompt('Название копии проекта', projectName + ' (копия)');
if (!newName || !newName.trim()) return;
const createResp = await fetch('/api/projects', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: newName.trim()})
});
if (!createResp.ok) {
alert('Не удалось создать копию проекта');
return;
}
const newProject = await createResp.json();
const listResp = await fetch('/api/projects/' + projectUUID + '/configs');
if (!listResp.ok) {
alert('Проект скопирован без квот (не удалось загрузить исходные квоты)');
loadProjects();
return;
}
const listData = await listResp.json();
const configs = listData.configurations || [];
for (const cfg of configs) {
await fetch('/api/projects/' + newProject.uuid + '/configs/' + cfg.uuid + '/clone', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: cfg.name})
});
}
loadProjects();
}
loadProjects();
document.getElementById('projects-search').addEventListener('input', function(e) {
projectsSearch = (e.target.value || '').trim();
currentPage = 1;
loadProjects();
});
document.getElementById('create-project-code').addEventListener('input', function() {
updateCreateProjectTrackerURL();
});
document.getElementById('create-project-tracker-url').addEventListener('input', function(e) {
createProjectTrackerManuallyEdited = (e.target.value || '').trim() !== createProjectLastAutoTrackerURL;
});
document.getElementById('create-project-code').addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
createProject();
}
});
document.getElementById('create-project-tracker-url').addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
createProject();
}
});
document.getElementById('create-project-modal').addEventListener('click', function(e) {
if (e.target === this) {
closeCreateProjectModal();
}
});
</script>
{{end}}
{{template "base" .}}

View File

@@ -158,6 +158,16 @@
}, 1000); // Check every second
}
async function requestRestartAndWait() {
showStatus('Перезапуск приложения...', 'info');
try {
await fetch('/api/restart', { method: 'POST' });
} catch (e) {
// Ignore network errors here: restart may break connection immediately.
}
checkServerReady();
}
document.getElementById('setup-form').addEventListener('submit', async function(e) {
e.preventDefault();
showStatus('Сохранение настроек...', 'info');
@@ -176,9 +186,8 @@
// Check if restart is required
if (data.restart_required) {
// In normal mode, restart must be done manually
setTimeout(() => {
showStatus('⚠️ Пожалуйста, перезапустите приложение вручную для применения изменений', 'warning');
requestRestartAndWait();
}, 2000);
} else {
// In setup mode, auto-restart is happening