41 Commits

Author SHA1 Message Date
Mikhail Chusavitin
726dccb07c feat: add projects flow and consolidate default project handling 2026-02-06 11:39:12 +03:00
Mikhail Chusavitin
38d7332a38 Update pricelist repository, service, and tests 2026-02-06 10:14:24 +03:00
Mikhail Chusavitin
c0beed021c Enforce pricelist write checks and auto-restart on DB settings change 2026-02-05 15:44:54 +03:00
Mikhail Chusavitin
08b95c293c Purge orphan sync queue entries before push 2026-02-05 15:17:06 +03:00
Mikhail Chusavitin
c418d6cfc3 Handle stale configuration sync events when local row is missing 2026-02-05 15:11:43 +03:00
Mikhail Chusavitin
548a256d04 Drop qt_users dependency for configs and track app version 2026-02-05 15:07:23 +03:00
Mikhail Chusavitin
77c00de97a Добавил шаблон для создания пользователя в БД 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
20056f3593 Embed assets and fix offline/sync/pricing issues 2026-02-03 21:58:02 +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
Mikhail Chusavitin
8d84484412 fix: fix online mode after offline-first architecture changes
- Fix nil pointer dereference in PricingHandler alert methods
- Add automatic MariaDB connection on startup if settings exist
- Update setupRouter to accept mariaDB as parameter
- Fix offline mode checks: use h.db instead of h.alertService
- Update setup handler to show restart required message
- Add warning status support in setup.html UI

This ensures that after saving connection settings, the application
works correctly in online mode after restart. All repositories are
properly initialized with MariaDB connection on startup.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 10:50:07 +03:00
2510d9e36e feat: show local pricelists in offline mode
**Problem:**
Pricelist page showed empty list in offline mode even though
local pricelists existed in SQLite cache.

**Solution:**
Modified PricelistHandler.List() to fallback to local pricelists:

1. Check if server list is empty (offline)
2. Load from localDB.GetLocalPricelists()
3. Convert LocalPricelist to summary format
4. Add "synced_from": "local" field
5. Add "offline": true flag

**Response format:**
```json
{
  "offline": true,
  "total": 4,
  "pricelists": [
    {
      "version": "2026-02-02-002",
      "created_by": "sync",
      "synced_from": "local",
      "is_active": true
    }
  ]
}
```

**Impact:**
-  Local pricelists visible in offline mode
-  UI can show cached pricelist versions
-  Users can browse pricelists without connection
-  Clear indication of local/remote source

Part of Phase 2.5: Full Offline Mode

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 07:19:43 +03:00
d7285fc730 fix: prevent PricingHandler panics in offline mode
**Problem:**
Opening /admin/pricing page caused nil pointer panic when offline
because PricingHandler methods accessed nil repositories.

**Solution:**
Added offline checks to all PricingHandler public methods:

1. **GetStats** - returns empty stats with offline flag
2. **ListComponents** - returns empty list with message
3. **GetComponentPricing** - returns 503 with offline error
4. **UpdatePrice** - blocks mutations with offline error
5. **RecalculateAll** - blocks recalculation with offline error
6. **PreviewPrice** - blocks preview with offline error

**Response format:**
```json
{
  "offline": true,
  "message": "Управление ценами доступно только в онлайн режиме",
  "components": [],
  "total": 0
}
```

**Impact:**
-  No panics when viewing admin pricing offline
-  Clear offline status indication
-  Graceful degradation for all operations
-  UI can detect offline and show appropriate message

Fixes Phase 2.5 admin panel offline issue.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 07:17:58 +03:00
e33a3f2c88 fix: enable component search and pricing in offline mode
**Problem:**
Configurator was broken in offline mode - no component search
and no price calculation because /api/components returned empty list.

**Solution:**
Added local component fallback to ComponentHandler:

1. **ComponentHandler with localDB** (component.go)
   - Added localDB parameter to NewComponentHandler
   - List() now fallbacks to local_components when offline
   - Converts LocalComponent to ComponentView format
   - Preserves prices from local cache

2. **Updated initialization** (main.go)
   - Pass localDB to NewComponentHandler

**Impact:**
-  Component search works offline
-  Prices load from local_components table
-  Configuration creation fully functional offline
-  Price calculation works with cached prices

**Testing:**
- Verified /api/components returns local components
- Verified current_price field populated from cache
- Search, filtering, and pagination work correctly

Fixes critical Phase 2.5 offline mode issue.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 07:15:03 +03:00
4735e2b9bb feat: always show admin menu with online checks for operations
**Changes:**

1. **Admin menu always visible** (base.html)
   - Removed 'hidden' class from "Администратор цен" link
   - Menu no longer depends on write permission check
   - Users can access pricing/pricelists pages in offline mode

2. **Online status checks for mutations** (admin_pricing.html)
   - Added checkOnlineStatus() helper function
   - createPricelist() checks online before creating
   - deletePricelist() checks online before deleting
   - Clear user feedback when operations blocked offline

**User Impact:**
- Admin menu accessible in both online and offline modes
- View-only access to pricelists when offline
- Clear error messages when attempting mutations offline
- Better offline-first UX

Part of Phase 2.5: Full Offline Mode

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 07:12:18 +03:00
cdf5cef2cf perf: eliminate connection timeouts in offline mode
Fixed application freezing in offline mode by preventing unnecessary
reconnection attempts:

**Changes:**

1. **DSN timeouts** (localdb.go)
   - Added timeout=3s, readTimeout=3s, writeTimeout=3s to MySQL DSN
   - Reduces connection timeout from 75s to 3s when MariaDB unreachable

2. **Fast /api/db-status** (main.go)
   - Check connection status before attempting GetDB()
   - Avoid reconnection attempts on every status request
   - Returns cached offline status instantly

3. **Optimized sync service** (sync/service.go)
   - GetStatus() checks connection status before GetDB()
   - NeedSync() skips server check if already offline
   - Prevents repeated 3s timeouts on every sync info request

4. **Local pricelist fallback** (pricelist.go)
   - GetLatest() returns local pricelists when server offline
   - UI can now display pricelist version in offline mode

5. **Better UI error messages** (configs.html)
   - 404 shows "Не загружен" instead of "Ошибка загрузки"
   - Network errors show "Не доступен" in gray
   - Distinguishes between missing data and real errors

**Performance:**
- Before: 75s timeout on every offline request
- After: <5ms response time in offline mode
- Cached error state prevents repeated connection attempts

**User Impact:**
- UI no longer freezes when loading pages offline
- Instant page loads and API responses
- Pricelist version displays correctly in offline mode
- Clear visual feedback for offline state

Fixes Phase 2.5 offline mode performance issues.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 07:10:53 +03:00
7f030e7db7 refactor: migrate sync service and handlers to use ConnectionManager
Updated sync-related code to use ConnectionManager instead of direct
database references:

- SyncService now creates repositories on-demand when connection available
- SyncHandler uses ConnectionManager for lazy DB access
- Added ComponentFilter and ListComponents to localdb for offline queries
- All sync operations check connection status before attempting MariaDB access

This completes the transition to offline-first architecture where all
database access goes through ConnectionManager.

Part of Phase 2.5: Full Offline Mode

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 23:29:36 +03:00
3d222b7f14 feat: add ConnectionManager for lazy database connections
Introduced ConnectionManager to support offline-first architecture:

- New internal/db/connection.go with thread-safe connection management
- Lazy connection establishment (5s timeout, 10s cooldown)
- Automatic ping caching (30s interval) to avoid excessive checks
- Updated middleware/offline.go to use ConnectionManager.IsOnline()
- Updated sync/worker.go to use ConnectionManager instead of direct DB

This enables the application to start without MariaDB and gracefully
handle offline/online transitions.

Part of Phase 2.5: Full Offline Mode

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 23:29:04 +03:00
c024b96de7 fix: enable instant startup and offline mode for server
Fixed two critical issues preventing offline-first operation:

1. **Instant startup** - Removed blocking GetDB() call during server
   initialization. Server now starts in <10ms instead of 1+ minute.
   - Changed setupRouter() to use lazy DB connection via ConnectionManager
   - mariaDB connection is now nil on startup, established only when needed
   - Fixes timeout issues when MariaDB is unreachable

2. **Offline mode nil pointer panics** - Added graceful degradation
   when database is offline:
   - ComponentService.GetCategories() returns DefaultCategories if repo is nil
   - ComponentService.List/GetByLotName checks for nil repo
   - PricelistService methods return empty/error responses in offline mode
   - All methods properly handle nil repositories

**Before**: Server startup took 1min+ and crashed with nil pointer panic
when trying to load /configurator page offline.

**After**: Server starts instantly and serves pages in offline mode using
DefaultCategories and SQLite data.

Related to Phase 2.5: Full Offline Mode (local-first architecture)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 23:28:14 +03:00
2c75a7ccb8 feat: improve admin pricing modal quote count display to show period and total counts 2026-02-02 21:34:51 +03:00
Mikhail Chusavitin
f25477a25e add todo 2026-02-02 19:44:45 +03:00
Mikhail Chusavitin
0bde12a39d fix: display only real sync errors in error count and list
- Added CountErroredChanges() method to count only pending changes with LastError
- Previously, error count included all pending changes, not just failed ones
- Added /api/sync/info endpoint with proper error count and error list
- Added sync info modal to display sync status, error count, and error details
- Made sync status indicators clickable to open the modal
- Fixed disconnect between "Error count: 4" and "No errors" in the list

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 17:19:52 +03:00
Mikhail Chusavitin
e0404186ad fix: remove duplicate showToast declaration causing JavaScript error
Root cause: admin_pricing.html declared 'const showToast' while base.html
already defined 'function showToast', causing SyntaxError that prevented
all JavaScript from executing on the admin pricing page.

Changes:
- Removed duplicate showToast declaration from admin_pricing.html (lines 206-210)
- Removed debug logging added in previous commit
- Kept immediate function calls in base.html to ensure early initialization

This fixes the issue where username and "Администратор цен" link
disappeared when navigating to /admin/pricing.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 14:54:13 +03:00
Mikhail Chusavitin
eda0e7cb47 debug: add logging to diagnose admin pricing page issue
- Added immediate calls to checkDbStatus() and checkWritePermission() in base.html
- Calls happen right after function definitions, before DOMContentLoaded
- Added console.log statements to track function execution and API responses
- Removed duplicate calls from admin_pricing.html to avoid conflicts
- This will help diagnose why username and admin link disappear on admin pricing page

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 14:51:38 +03:00
70 changed files with 9020 additions and 1380 deletions

4
.gitignore vendored
View File

@@ -13,6 +13,9 @@ config.yaml
/cron /cron
/bin/ /bin/
# Local Go build cache used in sandboxed runs
.gocache/
# ---> macOS # ---> macOS
# General # General
.DS_Store .DS_Store
@@ -41,3 +44,4 @@ Network Trash Folder
Temporary Items Temporary Items
.apdisk .apdisk
releases/

View File

@@ -46,30 +46,31 @@
**TODO:** **TODO:**
- ❌ Conflict resolution (Phase 4, last-write-wins default) - ❌ Conflict resolution (Phase 4, last-write-wins default)
### UI Improvements 🔶 IN PROGRESS ### UI Improvements ✅ MOSTLY DONE
**1. Sync icon + pricelist badge в header (tasks 4+2):** **1. Sync UI + pricelist badge: ✅ DONE**
- `sync_status.html`: заменить текст Online/Offline на SVG иконку - `sync_status.html`: SVG иконки Online/Offline (кликабельные → открывают модал)
- Кнопка sync → иконка (circular arrows) вместо текста - Кнопка sync → иконка circular arrows (только full sync)
- ❌ Dropdown при клике: Push changes, Full sync, статус последней синхронизации - ✅ Модальное окно "Статус системы" в `base.html` (info о БД, ошибки синхронизации)
- `configs.html`: рядом с кнопкой "Создать" показать badge с версией активного прайслиста - `configs.html`: badge с версией активного прайслиста
- Загружать через `/api/pricelists/latest` при DOMContentLoaded - Загрузка через `/api/pricelists/latest` при DOMContentLoaded
- ✅ Удалён dropdown с Push changes (упрощение UI)
**2. Прайслисты → вкладка в "Администратор цен" (task 1):** **2. Прайслисты → вкладка в "Администратор цен": ✅ DONE**
- `base.html`: убрать отдельную ссылку "Прайслисты" из навигации - `base.html`: убрана ссылка "Прайслисты" из навигации
- `admin_pricing.html`: добавить 4-ю вкладку "Прайслисты" - `admin_pricing.html`: добавлена вкладка "Прайслисты"
- ❌ Перенести логику из `pricelists.html` (table, create modal, CRUD) в эту вкладку - ✅ Логика перенесена из `pricelists.html` (table, create modal, CRUD)
- Route `/pricelists` → редирект на `/admin/pricing?tab=pricelists` или удалить - Route `/pricelists` → редирект на `/admin/pricing?tab=pricelists`
- ✅ Поддержка URL param `?tab=pricelists`
**3. Страница настроек: расширить + синхронизация (task 3):** **3. Модал "Настройка цены" - кол-во котировок с учётом периода: ❌ TODO**
- `setup.html`: переделать на `{{template "base" .}}` структуру - Текущее: показывает только общее кол-во котировок
- ❌ Увеличить до `max-w-4xl`, разделить на 2 секции - Новое: показывать `N (всего: M)` где N - за выбранный период, M - всего
-Секция A: Подключение к БД (текущая форма) -`admin_pricing.html`: обновить `#modal-quote-count`
-Секция B: Синхронизация данных: -`admin_pricing_handler.go`: в `/api/admin/pricing/preview` возвращать `quote_count_period` + `quote_count_total`
- Статус Online/Offline
- Кнопки: "Синхронизировать всё", "Обновить компоненты", "Обновить прайслисты" **4. Страница настроек: ❌ ОТЛОЖЕНО**
- Журнал синхронизации (последние N операций) - Перенесено в Phase 3 (после основных UI улучшений)
- ❌ Возможно: новый API endpoint для sync log
### Phase 3: Projects and Specifications ### Phase 3: Projects and Specifications
- qt_projects, qt_specifications tables (MariaDB) - qt_projects, qt_specifications tables (MariaDB)
@@ -133,9 +134,22 @@ Go 1.22+ | Gin | GORM | MariaDB 11 | SQLite (glebarez/sqlite) | htmx + Tailwind
## Commands ## Commands
```bash ```bash
go run ./cmd/server # Dev server # Development
go run ./cmd/cron -job=X # cleanup-pricelists | update-prices | update-popularity go run ./cmd/qfs # Dev server
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge ./cmd/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 ## Code Style

View File

@@ -60,7 +60,7 @@ localConfigService := services.NewLocalConfigurationService(
### Шаг 1: Обновить main.go ### Шаг 1: Обновить main.go
```go ```go
// В cmd/server/main.go // В cmd/qfs/main.go
syncService := sync.NewService(pricelistRepo, configRepo, local) syncService := sync.NewService(pricelistRepo, configRepo, local)
// Создать isOnline функцию // Создать isOnline функцию
@@ -165,7 +165,7 @@ type PendingChange struct {
```bash ```bash
# Compile # Compile
go build ./cmd/server go build ./cmd/qfs
# Run # Run
./quoteforge ./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/models/configuration.go` - добавлено поле `PriceUpdatedAt`
- `internal/services/configuration.go` - добавлен метод `RefreshPrices()` - `internal/services/configuration.go` - добавлен метод `RefreshPrices()`
- `internal/handlers/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 функции - `web/templates/index.html` - добавлена кнопка и JavaScript функции
- `migrations/004_add_price_updated_at.sql` - SQL миграция - `migrations/004_add_price_updated_at.sql` - SQL миграция
- `CLAUDE.md` - обновлена документация - `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)"

148
README.md
View File

@@ -82,9 +82,63 @@ auth:
### 3. Миграции базы данных ### 3. Миграции базы данных
```bash ```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 'DB_PASSWORD_PLACEHOLDER';
-- 2) Сбросить лишние права (без пересоздания пользователя)
REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'quote_user'@'%';
-- 3) Чтение данных для конфигуратора и синка
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'@'%';
-- 4) Работа с конфигурациями
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
FLUSH PRIVILEGES;
SHOW GRANTS FOR 'quote_user'@'%';
```
Важно:
- не выдавайте `INSERT/UPDATE/DELETE` на `qt_pricelists` и `qt_pricelist_items`, если пользователь не должен управлять прайслистами;
- если используется host-специфичный аккаунт (`'quote_user'@'192.168.x.x'`), назначьте права и для него;
- после смены DB-настроек через `/setup` приложение перезапускается автоматически и подхватывает нового пользователя.
### 4. Импорт метаданных компонентов ### 4. Импорт метаданных компонентов
```bash ```bash
@@ -95,15 +149,77 @@ go run ./cmd/importer
```bash ```bash
# Development # Development
go run ./cmd/server go run ./cmd/qfs
# Production # Production (with Makefile - recommended)
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge ./cmd/server make build-release # Builds with version info
./bin/quoteforge ./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 Приложение будет доступно по адресу: 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 ## Docker
```bash ```bash
@@ -159,8 +275,23 @@ GET /api/components # Список компонентов
POST /api/quote/calculate # Расчёт цены POST /api/quote/calculate # Расчёт цены
POST /api/export/xlsx # Экспорт в Excel POST /api/export/xlsx # Экспорт в Excel
GET /api/configs # Сохранённые конфигурации 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 ## 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. 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 +340,13 @@ go run ./cmd/cron -job=update-popularity
```bash ```bash
# Запуск в режиме разработки (hot reload) # Запуск в режиме разработки (hot reload)
go run ./cmd/server go run ./cmd/qfs
# Запуск тестов # Запуск тестов
go test ./... go test ./...
# Сборка для Linux # Сборка для 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 +360,9 @@ CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge ./cmd/server
| `QF_DB_PASSWORD` | Пароль БД | — | | `QF_DB_PASSWORD` | Пароль БД | — |
| `QF_JWT_SECRET` | Секрет для JWT | — | | `QF_JWT_SECRET` | Секрет для JWT | — |
| `QF_SERVER_PORT` | Порт сервера | 8080 | | `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 |
## Интеграция с существующей БД ## Интеграция с существующей БД

21
assets_embed.go Normal file
View File

@@ -0,0 +1,21 @@
package quoteforge
import (
"embed"
"io/fs"
)
// TemplatesFS contains HTML templates embedded into the binary.
//
//go:embed web/templates/*.html web/templates/partials/*.html
var TemplatesFS embed.FS
// StaticFiles contains static assets (CSS, JS, etc.) embedded into the binary.
//
//go:embed web/static/*
var StaticFiles embed.FS
// StaticFS returns a filesystem rooted at web/static for serving static assets.
func StaticFS() (fs.FS, error) {
return fs.Sub(StaticFiles, "web/static")
}

View File

@@ -6,6 +6,7 @@ import (
"log" "log"
"time" "time"
"git.mchus.pro/mchus/quoteforge/internal/appstate"
"git.mchus.pro/mchus/quoteforge/internal/config" "git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
@@ -16,7 +17,11 @@ import (
func main() { func main() {
configPath := flag.String("config", "config.yaml", "path to config file") 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") dryRun := flag.Bool("dry-run", false, "show what would be migrated without actually doing it")
flag.Parse() flag.Parse()
@@ -61,7 +66,7 @@ func main() {
// Get all configurations from MariaDB // Get all configurations from MariaDB
var configs []models.Configuration 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) log.Fatalf("Failed to fetch configurations: %v", err)
} }
@@ -72,9 +77,9 @@ func main() {
if *dryRun { if *dryRun {
log.Println("\n[DRY RUN] Would migrate the following configurations:") log.Println("\n[DRY RUN] Would migrate the following configurations:")
for _, c := range configs { for _, c := range configs {
userName := "unknown" userName := c.OwnerUsername
if c.User != nil { if userName == "" {
userName = c.User.Username userName = "unknown"
} }
log.Printf(" - %s (UUID: %s, User: %s, Items: %d)", c.Name, c.UUID, userName, len(c.Items)) log.Printf(" - %s (UUID: %s, User: %s, Items: %d)", c.Name, c.UUID, userName, len(c.Items))
} }
@@ -112,6 +117,7 @@ func main() {
localConfig := &localdb.LocalConfiguration{ localConfig := &localdb.LocalConfiguration{
UUID: c.UUID, UUID: c.UUID,
ServerID: &c.ID, ServerID: &c.ID,
ProjectUUID: c.ProjectUUID,
Name: c.Name, Name: c.Name,
Items: localItems, Items: localItems,
TotalPrice: c.TotalPrice, TotalPrice: c.TotalPrice,
@@ -123,7 +129,8 @@ func main() {
UpdatedAt: now, UpdatedAt: now,
SyncedAt: &now, SyncedAt: &now,
SyncStatus: "synced", SyncStatus: "synced",
OriginalUserID: c.UserID, OriginalUserID: derefUint(c.UserID),
OriginalUsername: c.OwnerUsername,
} }
if err := local.SaveConfiguration(localConfig); err != nil { 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") 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
}

1324
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,680 +0,0 @@
package main
import (
"context"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"strconv"
"syscall"
"time"
"github.com/gin-gonic/gin"
"git.mchus.pro/mchus/quoteforge/internal/config"
"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"
"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 {
// Use defaults if config file doesn't exist
slog.Info("config file not found, using defaults", "path", *configPath)
cfg = &config.Config{}
}
setConfigDefaults(cfg)
setupLogger(cfg.Logging)
// Get DSN from local SQLite
dsn, err := local.GetDSN()
if err != nil {
slog.Error("failed to get database settings", "error", err)
os.Exit(1)
}
// Connect to MariaDB
db, err := setupDatabaseFromDSN(dsn)
if err != nil {
slog.Error("failed to connect to database", "error", err)
slog.Info("you may need to reconfigure connection at /setup")
os.Exit(1)
}
dbUser := local.GetDBUser()
// Ensure DB user exists in qt_users table (for foreign key constraint)
dbUserID, err := models.EnsureDBUser(db, dbUser)
if err != nil {
slog.Error("failed to ensure DB user exists", "error", err)
os.Exit(1)
}
slog.Info("starting QuoteForge server",
"host", cfg.Server.Host,
"port", cfg.Server.Port,
"db_user", dbUser,
"db_user_id", dbUserID,
)
if *migrate {
slog.Info("running database migrations...")
if err := models.Migrate(db); err != nil {
slog.Error("migration failed", "error", err)
os.Exit(1)
}
if err := models.SeedCategories(db); 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(db, cfg, local, dbUserID)
if err != nil {
slog.Error("failed to setup router", "error", err)
os.Exit(1)
}
// Start background sync worker
workerCtx, workerCancel := context.WithCancel(context.Background())
defer workerCancel()
syncWorker := sync.NewWorker(syncService, db, 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)
setupHandler, err := handlers.NewSetupHandler(local, "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())
router.Static("/static", "web/static")
// 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(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUserID uint) (*gin.Engine, *sync.Service, error) {
// Repositories
componentRepo := repository.NewComponentRepository(db)
categoryRepo := repository.NewCategoryRepository(db)
priceRepo := repository.NewPriceRepository(db)
alertRepo := repository.NewAlertRepository(db)
statsRepo := repository.NewStatsRepository(db)
pricelistRepo := repository.NewPricelistRepository(db)
configRepo := repository.NewConfigurationRepository(db)
// Services
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(db, pricelistRepo, componentRepo)
syncService := sync.NewService(pricelistRepo, configRepo, local)
// isOnline function for local-first architecture
isOnline := func() bool {
sqlDB, err := db.DB()
if err != nil {
return false
}
return sqlDB.Ping() == nil
}
// Local-first configuration service (replaces old ConfigurationService)
configService := services.NewLocalConfigurationService(local, syncService, quoteService, isOnline)
// Handlers
componentHandler := handlers.NewComponentHandler(componentService)
quoteHandler := handlers.NewQuoteHandler(quoteService)
exportHandler := handlers.NewExportHandler(exportService, configService, componentService)
pricingHandler := handlers.NewPricingHandler(db, pricingService, alertService, componentRepo, priceRepo, statsRepo)
pricelistHandler := handlers.NewPricelistHandler(pricelistService, local)
syncHandler, err := handlers.NewSyncHandler(local, syncService, db, "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, "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(db, local))
// Static files
router.Static("/static", "web/static")
// 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 = true
var dbError string
sqlDB, err := db.DB()
if err != nil {
dbOK = false
dbError = err.Error()
} else if err := sqlDB.Ping(); err != nil {
dbOK = false
dbError = err.Error()
}
db.Table("lot").Count(&lotCount)
db.Table("lot_log").Count(&lotLogCount)
db.Table("qt_lot_metadata").Count(&metadataCount)
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.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 # Copy this file to config.yaml and update values
server: server:
host: "0.0.0.0" host: "127.0.0.1" # Use 0.0.0.0 to listen on all interfaces
port: 8080 port: 8080
mode: "release" # debug | release mode: "release" # debug | release
read_timeout: "30s" 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

@@ -106,7 +106,7 @@ func Load(path string) (*Config, error) {
func (c *Config) setDefaults() { func (c *Config) setDefaults() {
if c.Server.Host == "" { if c.Server.Host == "" {
c.Server.Host = "0.0.0.0" c.Server.Host = "127.0.0.1"
} }
if c.Server.Port == 0 { if c.Server.Port == 0 {
c.Server.Port = 8080 c.Server.Port = 8080

334
internal/db/connection.go Normal file
View File

@@ -0,0 +1,334 @@
package db
import (
"context"
"fmt"
"sync"
"time"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
const (
defaultConnectTimeout = 5 * time.Second
defaultPingInterval = 30 * time.Second
defaultReconnectCooldown = 10 * time.Second
maxOpenConns = 10
maxIdleConns = 2
connMaxLifetime = 5 * time.Minute
)
// ConnectionStatus represents the current status of the database connection
type ConnectionStatus struct {
IsConnected bool
LastCheck time.Time
LastError string // empty if no error
DSNHost string // host:port for display (without password!)
}
// 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)
}
// NewConnectionManager creates a new ConnectionManager instance
func NewConnectionManager(localDB *localdb.LocalDB) *ConnectionManager {
return &ConnectionManager{
localDB: localDB,
connectTimeout: defaultConnectTimeout,
pingInterval: defaultPingInterval,
reconnectCooldown: defaultReconnectCooldown,
db: nil,
lastError: nil,
lastCheck: time.Time{},
}
}
// GetDB returns the current database connection, establishing it if needed
// Thread-safe and respects connection cooldowns
func (cm *ConnectionManager) GetDB() (*gorm.DB, error) {
// Handle case where localDB is nil
if cm.localDB == nil {
return nil, fmt.Errorf("local database not initialized")
}
// First check if we already have a valid connection
cm.mu.RLock()
if cm.db != nil {
// Check if connection is still valid and within ping interval
if time.Since(cm.lastCheck) < cm.pingInterval {
cm.mu.RUnlock()
return cm.db, nil
}
}
cm.mu.RUnlock()
// Upgrade to write lock
cm.mu.Lock()
defer cm.mu.Unlock()
// Double-check: someone else might have connected while we were waiting for the write lock
if cm.db != nil {
// Check if connection is still valid and within ping interval
if time.Since(cm.lastCheck) < cm.pingInterval {
return cm.db, nil
}
}
// Check if we're in cooldown period after a failed attempt
if cm.lastError != nil && time.Since(cm.lastCheck) < cm.reconnectCooldown {
return nil, cm.lastError
}
// 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
}
// Update last check time and return success
cm.lastCheck = time.Now()
cm.lastError = nil
return cm.db, nil
}
// connect establishes a new database connection
func (cm *ConnectionManager) connect() error {
// Get DSN from local settings
dsn, err := cm.localDB.GetDSN()
if err != nil {
return fmt.Errorf("getting DSN: %w", err)
}
// Create context with timeout
ctx, cancel := context.WithTimeout(context.Background(), cm.connectTimeout)
defer cancel()
// Open database connection
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
return fmt.Errorf("opening database connection: %w", err)
}
// Test the connection
sqlDB, err := db.DB()
if err != nil {
return fmt.Errorf("getting sql.DB: %w", err)
}
// Ping with timeout
if err = sqlDB.PingContext(ctx); err != nil {
return fmt.Errorf("pinging database: %w", err)
}
// Set connection pool settings
sqlDB.SetMaxOpenConns(maxOpenConns)
sqlDB.SetMaxIdleConns(maxIdleConns)
sqlDB.SetConnMaxLifetime(connMaxLifetime)
// Store the connection
cm.db = db
return nil
}
// 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()
isDisconnected := cm.db == nil
lastErr := cm.lastError
checkedRecently := time.Since(cm.lastCheck) < cm.pingInterval
cm.mu.RUnlock()
// 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()
// Double-check after acquiring write lock
if cm.db == nil {
return false
}
// Perform ping with timeout
ctx, cancel := context.WithTimeout(context.Background(), cm.connectTimeout)
defer cancel()
sqlDB, err := cm.db.DB()
if err != nil {
cm.lastError = err
cm.lastCheck = time.Now()
cm.db = nil
return false
}
if err = sqlDB.PingContext(ctx); err != nil {
cm.lastError = err
cm.lastCheck = time.Now()
cm.db = nil
return false
}
// Update last check time and return success
cm.lastCheck = time.Now()
cm.lastError = nil
return true
}
// TryConnect forces a new connection attempt (for UI "Reconnect" button)
// Ignores cooldown period
func (cm *ConnectionManager) TryConnect() error {
cm.mu.Lock()
defer cm.mu.Unlock()
// Attempt to connect
err := cm.connect()
if err != nil {
cm.lastError = err
cm.lastCheck = time.Now()
return err
}
// Update last check time and clear error
cm.lastCheck = time.Now()
cm.lastError = nil
return nil
}
// Disconnect closes the current database connection
func (cm *ConnectionManager) Disconnect() {
cm.mu.Lock()
defer cm.mu.Unlock()
if cm.db != nil {
sqlDB, err := cm.db.DB()
if err == nil {
sqlDB.Close()
}
}
cm.db = nil
cm.lastError = nil
}
// GetLastError returns the last connection error (thread-safe)
func (cm *ConnectionManager) GetLastError() error {
cm.mu.RLock()
defer cm.mu.RUnlock()
return cm.lastError
}
// GetStatus returns the current connection status
func (cm *ConnectionManager) GetStatus() ConnectionStatus {
cm.mu.RLock()
defer cm.mu.RUnlock()
status := ConnectionStatus{
IsConnected: cm.db != nil,
LastCheck: cm.lastCheck,
LastError: "",
DSNHost: "",
}
if cm.lastError != nil {
status.LastError = cm.lastError.Error()
}
// Extract host from DSN for display
if cm.localDB != nil {
if dsn, err := cm.localDB.GetDSN(); err == nil {
// Parse DSN to extract host:port
// Format: user:password@tcp(host:port)/database?...
status.DSNHost = extractHostFromDSN(dsn)
}
}
return status
}
// extractHostFromDSN extracts the host:port part from a DSN string
func extractHostFromDSN(dsn string) string {
// Find the tcp( part
tcpStart := 0
if tcpStart = len("tcp("); tcpStart < len(dsn) && dsn[tcpStart] == '(' {
// Look for the closing parenthesis
parenEnd := -1
for i := tcpStart + 1; i < len(dsn); i++ {
if dsn[i] == ')' {
parenEnd = i
break
}
}
if parenEnd != -1 {
// Extract host:port part between tcp( and )
hostPort := dsn[tcpStart+1 : parenEnd]
return hostPort
}
}
// Fallback: try to find host:port by looking for @tcp( pattern
atIndex := -1
for i := 0; i < len(dsn)-4; i++ {
if dsn[i:i+4] == "@tcp" {
atIndex = i
break
}
}
if atIndex != -1 {
// Look for the opening parenthesis after @tcp
parenStart := -1
for i := atIndex + 4; i < len(dsn); i++ {
if dsn[i] == '(' {
parenStart = i
break
}
}
if parenStart != -1 {
// Look for the closing parenthesis
parenEnd := -1
for i := parenStart + 1; i < len(dsn); i++ {
if dsn[i] == ')' {
parenEnd = i
break
}
}
if parenEnd != -1 {
hostPort := dsn[parenStart+1 : parenEnd]
return hostPort
}
}
}
// If we can't parse it, return empty string
return ""
}

View File

@@ -4,17 +4,22 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"github.com/gin-gonic/gin" "git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/repository" "git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services" "git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/gin-gonic/gin"
) )
type ComponentHandler struct { type ComponentHandler struct {
componentService *services.ComponentService componentService *services.ComponentService
localDB *localdb.LocalDB
} }
func NewComponentHandler(componentService *services.ComponentService) *ComponentHandler { func NewComponentHandler(componentService *services.ComponentService, localDB *localdb.LocalDB) *ComponentHandler {
return &ComponentHandler{componentService: componentService} return &ComponentHandler{
componentService: componentService,
localDB: localDB,
}
} }
func (h *ComponentHandler) List(c *gin.Context) { func (h *ComponentHandler) List(c *gin.Context) {
@@ -34,6 +39,46 @@ func (h *ComponentHandler) List(c *gin.Context) {
return return
} }
// If offline mode (empty result), fallback to local components
isOffline := false
if v, ok := c.Get("is_offline"); ok {
if b, ok := v.(bool); ok {
isOffline = b
}
}
if isOffline && result.Total == 0 && h.localDB != nil {
localFilter := localdb.ComponentFilter{
Category: filter.Category,
Search: filter.Search,
HasPrice: filter.HasPrice,
}
offset := (page - 1) * perPage
localComps, total, err := h.localDB.ListComponents(localFilter, offset, perPage)
if err == nil && len(localComps) > 0 {
// Convert local components to ComponentView format
components := make([]services.ComponentView, len(localComps))
for i, lc := range localComps {
components[i] = services.ComponentView{
LotName: lc.LotName,
Description: lc.LotDescription,
Category: lc.Category,
CategoryName: lc.Category, // No translation in local mode
Model: lc.Model,
CurrentPrice: lc.CurrentPrice,
}
}
c.JSON(http.StatusOK, &services.ComponentListResult{
Components: components,
Total: total,
Page: page,
PerPage: perPage,
})
return
}
}
c.JSON(http.StatusOK, result) c.JSON(http.StatusOK, result)
} }

View File

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

View File

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

View File

@@ -29,6 +29,36 @@ func (h *PricelistHandler) List(c *gin.Context) {
return return
} }
// If offline (empty list), fallback to local pricelists
if total == 0 && h.localDB != nil {
localPLs, err := h.localDB.GetLocalPricelists()
if err == nil && len(localPLs) > 0 {
// Convert to PricelistSummary format
summaries := make([]map[string]interface{}, len(localPLs))
for i, lpl := range localPLs {
summaries[i] = map[string]interface{}{
"id": lpl.ServerID,
"version": lpl.Version,
"created_by": "sync",
"item_count": 0, // Not tracked
"usage_count": 0, // Not tracked in local
"is_active": true,
"created_at": lpl.CreatedAt,
"synced_from": "local",
}
}
c.JSON(http.StatusOK, gin.H{
"pricelists": summaries,
"total": len(summaries),
"page": page,
"per_page": perPage,
"offline": true,
})
return
}
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"pricelists": pricelists, "pricelists": pricelists,
"total": total, "total": total,
@@ -57,6 +87,15 @@ func (h *PricelistHandler) Get(c *gin.Context) {
// Create creates a new pricelist from current prices // Create creates a new pricelist from current prices
func (h *PricelistHandler) Create(c *gin.Context) { 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
}
// Get the database username as the creator // Get the database username as the creator
createdBy := h.localDB.GetDBUser() createdBy := h.localDB.GetDBUser()
if createdBy == "" { if createdBy == "" {
@@ -74,6 +113,15 @@ func (h *PricelistHandler) Create(c *gin.Context) {
// Delete deletes a pricelist by ID // Delete deletes a pricelist by ID
func (h *PricelistHandler) Delete(c *gin.Context) { 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") idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32) id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil { if err != nil {
@@ -124,9 +172,33 @@ func (h *PricelistHandler) CanWrite(c *gin.Context) {
// GetLatest returns the most recent active pricelist // GetLatest returns the most recent active pricelist
func (h *PricelistHandler) GetLatest(c *gin.Context) { func (h *PricelistHandler) GetLatest(c *gin.Context) {
// Try to get from server first
pl, err := h.service.GetLatestActive() pl, err := h.service.GetLatestActive()
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no active pricelists found"}) // 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()
if localErr != nil {
// No local pricelists either
c.JSON(http.StatusNotFound, gin.H{
"error": "no pricelists available",
"local_error": localErr.Error(),
})
return
}
// Return local pricelist
c.JSON(http.StatusOK, gin.H{
"id": localPL.ServerID,
"version": localPL.Version,
"created_by": "sync",
"item_count": 0, // Not tracked in local pricelists
"is_active": true,
"created_at": localPL.CreatedAt,
"synced_from": "local",
})
return return
} }

View File

@@ -68,6 +68,17 @@ func NewPricingHandler(
} }
func (h *PricingHandler) GetStats(c *gin.Context) { func (h *PricingHandler) GetStats(c *gin.Context) {
// Check if we're in offline mode
if h.statsRepo == nil || h.alertService == nil {
c.JSON(http.StatusOK, gin.H{
"new_alerts_count": 0,
"top_components": []interface{}{},
"trending_components": []interface{}{},
"offline": true,
})
return
}
newAlerts, _ := h.alertService.GetNewAlertsCount() newAlerts, _ := h.alertService.GetNewAlertsCount()
topComponents, _ := h.statsRepo.GetTopComponents(10) topComponents, _ := h.statsRepo.GetTopComponents(10)
trendingComponents, _ := h.statsRepo.GetTrendingComponents(10) trendingComponents, _ := h.statsRepo.GetTrendingComponents(10)
@@ -86,6 +97,19 @@ type ComponentWithCount struct {
} }
func (h *PricingHandler) ListComponents(c *gin.Context) { func (h *PricingHandler) ListComponents(c *gin.Context) {
// Check if we're in offline mode
if h.componentRepo == nil {
c.JSON(http.StatusOK, gin.H{
"components": []ComponentWithCount{},
"total": 0,
"page": 1,
"per_page": 20,
"offline": true,
"message": "Управление ценами доступно только в онлайн режиме",
})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20")) perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
@@ -213,6 +237,15 @@ func (h *PricingHandler) expandMetaPrices(metaPrices, excludeLot string) []strin
} }
func (h *PricingHandler) GetComponentPricing(c *gin.Context) { func (h *PricingHandler) GetComponentPricing(c *gin.Context) {
// Check if we're in offline mode
if h.componentRepo == nil || h.pricingService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Управление ценами доступно только в онлайн режиме",
"offline": true,
})
return
}
lotName := c.Param("lot_name") lotName := c.Param("lot_name")
component, err := h.componentRepo.GetByLotName(lotName) component, err := h.componentRepo.GetByLotName(lotName)
@@ -248,6 +281,15 @@ type UpdatePriceRequest struct {
} }
func (h *PricingHandler) UpdatePrice(c *gin.Context) { func (h *PricingHandler) UpdatePrice(c *gin.Context) {
// Check if we're in offline mode
if h.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Обновление цен доступно только в онлайн режиме",
"offline": true,
})
return
}
var req UpdatePriceRequest var req UpdatePriceRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -409,6 +451,15 @@ func (h *PricingHandler) recalculateSinglePrice(lotName string) {
} }
func (h *PricingHandler) RecalculateAll(c *gin.Context) { func (h *PricingHandler) RecalculateAll(c *gin.Context) {
// Check if we're in offline mode
if h.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Пересчёт цен доступен только в онлайн режиме",
"offline": true,
})
return
}
// Set headers for SSE // Set headers for SSE
c.Header("Content-Type", "text/event-stream") c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache") c.Header("Cache-Control", "no-cache")
@@ -588,6 +639,18 @@ func (h *PricingHandler) RecalculateAll(c *gin.Context) {
} }
func (h *PricingHandler) ListAlerts(c *gin.Context) { func (h *PricingHandler) ListAlerts(c *gin.Context) {
// Check if we're in offline mode
if h.db == nil {
c.JSON(http.StatusOK, gin.H{
"alerts": []interface{}{},
"total": 0,
"page": 1,
"per_page": 20,
"offline": true,
})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20")) perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
@@ -613,6 +676,15 @@ func (h *PricingHandler) ListAlerts(c *gin.Context) {
} }
func (h *PricingHandler) AcknowledgeAlert(c *gin.Context) { func (h *PricingHandler) AcknowledgeAlert(c *gin.Context) {
// Check if we're in offline mode
if h.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Управление алертами доступно только в онлайн режиме",
"offline": true,
})
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 32) id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"})
@@ -628,6 +700,15 @@ func (h *PricingHandler) AcknowledgeAlert(c *gin.Context) {
} }
func (h *PricingHandler) ResolveAlert(c *gin.Context) { func (h *PricingHandler) ResolveAlert(c *gin.Context) {
// Check if we're in offline mode
if h.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Управление алертами доступно только в онлайн режиме",
"offline": true,
})
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 32) id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"})
@@ -643,6 +724,15 @@ func (h *PricingHandler) ResolveAlert(c *gin.Context) {
} }
func (h *PricingHandler) IgnoreAlert(c *gin.Context) { func (h *PricingHandler) IgnoreAlert(c *gin.Context) {
// Check if we're in offline mode
if h.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Управление алертами доступно только в онлайн режиме",
"offline": true,
})
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 32) id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"})
@@ -667,6 +757,15 @@ type PreviewPriceRequest struct {
} }
func (h *PricingHandler) PreviewPrice(c *gin.Context) { func (h *PricingHandler) PreviewPrice(c *gin.Context) {
// Check if we're in offline mode
if h.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Предпросмотр цены доступен только в онлайн режиме",
"offline": true,
})
return
}
var req PreviewPriceRequest var req PreviewPriceRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -708,8 +807,8 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) {
medianAllTime = &median medianAllTime = &median
} }
// Get quote count (from all relevant lots) // Get quote count (from all relevant lots) - total count
var quoteCount int64 var quoteCountTotal int64
for _, lotName := range lotNames { for _, lotName := range lotNames {
var count int64 var count int64
if strings.HasSuffix(lotName, "*") { if strings.HasSuffix(lotName, "*") {
@@ -718,7 +817,25 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) {
} else { } else {
h.db.Model(&models.LotLog{}).Where("lot = ?", lotName).Count(&count) h.db.Model(&models.LotLog{}).Where("lot = ?", lotName).Count(&count)
} }
quoteCount += count quoteCountTotal += count
}
// Get quote count for specified period (if period is > 0)
var quoteCountPeriod int64
if req.PeriodDays > 0 {
for _, lotName := range lotNames {
var count int64
if strings.HasSuffix(lotName, "*") {
pattern := strings.TrimSuffix(lotName, "*") + "%"
h.db.Raw(`SELECT COUNT(*) FROM lot_log WHERE lot LIKE ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY)`, pattern, req.PeriodDays).Scan(&count)
} else {
h.db.Raw(`SELECT COUNT(*) FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY)`, lotName, req.PeriodDays).Scan(&count)
}
quoteCountPeriod += count
}
} else {
// If no period specified, period count equals total count
quoteCountPeriod = quoteCountTotal
} }
// Get last received price (from the main lot only) // Get last received price (from the main lot only)
@@ -777,7 +894,8 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) {
"current_price": comp.CurrentPrice, "current_price": comp.CurrentPrice,
"median_all_time": medianAllTime, "median_all_time": medianAllTime,
"new_price": newPrice, "new_price": newPrice,
"quote_count": quoteCount, "quote_count_total": quoteCountTotal,
"quote_count_period": quoteCountPeriod,
"manual_price": comp.ManualPrice, "manual_price": comp.ManualPrice,
"last_price": lastPrice.Price, "last_price": lastPrice.Price,
"last_price_date": lastPrice.Date, "last_price_date": lastPrice.Date,

View File

@@ -3,13 +3,17 @@ package handlers
import ( import (
"fmt" "fmt"
"html/template" "html/template"
"log/slog"
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"time" "time"
"github.com/gin-gonic/gin" qfassets "git.mchus.pro/mchus/quoteforge"
"git.mchus.pro/mchus/quoteforge/internal/db"
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
"github.com/gin-gonic/gin"
"gorm.io/driver/mysql" "gorm.io/driver/mysql"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
@@ -17,11 +21,12 @@ import (
type SetupHandler struct { type SetupHandler struct {
localDB *localdb.LocalDB localDB *localdb.LocalDB
connMgr *db.ConnectionManager
templates map[string]*template.Template templates map[string]*template.Template
restartSig chan struct{} restartSig chan struct{}
} }
func NewSetupHandler(localDB *localdb.LocalDB, templatesPath string, restartSig chan struct{}) (*SetupHandler, error) { func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, templatesPath string, restartSig chan struct{}) (*SetupHandler, error) {
funcMap := template.FuncMap{ funcMap := template.FuncMap{
"sub": func(a, b int) int { return a - b }, "sub": func(a, b int) int { return a - b },
"add": func(a, b int) int { return a + b }, "add": func(a, b int) int { return a + b },
@@ -31,7 +36,13 @@ func NewSetupHandler(localDB *localdb.LocalDB, templatesPath string, restartSig
// Load setup template (standalone, no base needed) // Load setup template (standalone, no base needed)
setupPath := filepath.Join(templatesPath, "setup.html") setupPath := filepath.Join(templatesPath, "setup.html")
tmpl, err := template.New("").Funcs(funcMap).ParseFiles(setupPath) var tmpl *template.Template
var err error
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(setupPath)
} else {
tmpl, err = template.New("").Funcs(funcMap).ParseFS(qfassets.TemplatesFS, "web/templates/setup.html")
}
if err != nil { if err != nil {
return nil, fmt.Errorf("parsing setup template: %w", err) return nil, fmt.Errorf("parsing setup template: %w", err)
} }
@@ -39,6 +50,7 @@ func NewSetupHandler(localDB *localdb.LocalDB, templatesPath string, restartSig
return &SetupHandler{ return &SetupHandler{
localDB: localDB, localDB: localDB,
connMgr: connMgr,
templates: templates, templates: templates,
restartSig: restartSig, restartSig: restartSig,
}, nil }, nil
@@ -136,6 +148,8 @@ func (h *SetupHandler) TestConnection(c *gin.Context) {
// SaveConnection saves the connection settings and signals restart // SaveConnection saves the connection settings and signals restart
func (h *SetupHandler) SaveConnection(c *gin.Context) { func (h *SetupHandler) SaveConnection(c *gin.Context) {
existingSettings, _ := h.localDB.GetSettings()
host := c.PostForm("host") host := c.PostForm("host")
portStr := c.PostForm("port") portStr := c.PostForm("port")
database := c.PostForm("database") database := c.PostForm("database")
@@ -181,16 +195,38 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) {
return return
} }
// Try to connect immediately to verify settings
if h.connMgr != nil {
if err := h.connMgr.TryConnect(); err != nil {
slog.Warn("failed to connect after saving settings", "error", err)
} else {
slog.Info("successfully connected to database after saving settings")
}
}
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{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "Settings saved. Restarting application...", "message": "Settings saved.",
"restart_required": settingsChanged,
"restart_queued": restartQueued,
}) })
// Signal restart after response is sent // Signal restart after response is sent (if restart signal is configured)
if h.restartSig != nil { if restartQueued {
go func() { go func() {
time.Sleep(500 * time.Millisecond) // Give time for response to be sent time.Sleep(500 * time.Millisecond) // Give time for response to be sent
h.restartSig <- struct{}{} select {
case h.restartSig <- struct{}{}:
default:
}
}() }()
} }
} }

View File

@@ -4,28 +4,36 @@ import (
"html/template" "html/template"
"log/slog" "log/slog"
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"time" "time"
"github.com/gin-gonic/gin" qfassets "git.mchus.pro/mchus/quoteforge"
"git.mchus.pro/mchus/quoteforge/internal/db"
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/services/sync" "git.mchus.pro/mchus/quoteforge/internal/services/sync"
"gorm.io/gorm" "github.com/gin-gonic/gin"
) )
// SyncHandler handles sync API endpoints // SyncHandler handles sync API endpoints
type SyncHandler struct { type SyncHandler struct {
localDB *localdb.LocalDB localDB *localdb.LocalDB
syncService *sync.Service syncService *sync.Service
mariaDB *gorm.DB connMgr *db.ConnectionManager
tmpl *template.Template tmpl *template.Template
} }
// NewSyncHandler creates a new sync handler // NewSyncHandler creates a new sync handler
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, mariaDB *gorm.DB, templatesPath string) (*SyncHandler, error) { func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, templatesPath string) (*SyncHandler, error) {
// Load sync_status partial template // Load sync_status partial template
partialPath := filepath.Join(templatesPath, "partials", "sync_status.html") partialPath := filepath.Join(templatesPath, "partials", "sync_status.html")
tmpl, err := template.ParseFiles(partialPath) var tmpl *template.Template
var err error
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
tmpl, err = template.ParseFiles(partialPath)
} else {
tmpl, err = template.ParseFS(qfassets.TemplatesFS, "web/templates/partials/sync_status.html")
}
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -33,7 +41,7 @@ func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, mariaDB
return &SyncHandler{ return &SyncHandler{
localDB: localDB, localDB: localDB,
syncService: syncService, syncService: syncService,
mariaDB: mariaDB, connMgr: connMgr,
tmpl: tmpl, tmpl: tmpl,
}, nil }, nil
} }
@@ -109,7 +117,17 @@ func (h *SyncHandler) SyncComponents(c *gin.Context) {
return return
} }
result, err := h.localDB.SyncComponents(h.mariaDB) // Get database connection from ConnectionManager
mariaDB, err := h.connMgr.GetDB()
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"success": false,
"error": "Database connection failed: " + err.Error(),
})
return
}
result, err := h.localDB.SyncComponents(mariaDB)
if err != nil { if err != nil {
slog.Error("component sync failed", "error", err) slog.Error("component sync failed", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
@@ -181,7 +199,16 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
var componentsSynced, pricelistsSynced int var componentsSynced, pricelistsSynced int
// Sync components // Sync components
compResult, err := h.localDB.SyncComponents(h.mariaDB) mariaDB, err := h.connMgr.GetDB()
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"success": false,
"error": "Database connection failed: " + err.Error(),
})
return
}
compResult, err := h.localDB.SyncComponents(mariaDB)
if err != nil { if err != nil {
slog.Error("component sync failed during full sync", "error", err) slog.Error("component sync failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
@@ -215,16 +242,7 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
// checkOnline checks if MariaDB is accessible // checkOnline checks if MariaDB is accessible
func (h *SyncHandler) checkOnline() bool { func (h *SyncHandler) checkOnline() bool {
sqlDB, err := h.mariaDB.DB() return h.connMgr.IsOnline()
if err != nil {
return false
}
if err := sqlDB.Ping(); err != nil {
return false
}
return true
} }
// PushPendingChanges pushes all pending changes to the server // PushPendingChanges pushes all pending changes to the server
@@ -282,6 +300,70 @@ func (h *SyncHandler) GetPendingChanges(c *gin.Context) {
}) })
} }
// SyncInfoResponse represents sync information
type SyncInfoResponse struct {
LastSyncAt *time.Time `json:"last_sync_at"`
IsOnline bool `json:"is_online"`
ErrorCount int `json:"error_count"`
Errors []SyncError `json:"errors,omitempty"`
}
// SyncError represents a sync error
type SyncError struct {
Timestamp time.Time `json:"timestamp"`
Message string `json:"message"`
}
// GetInfo returns sync information for modal
// GET /api/sync/info
func (h *SyncHandler) GetInfo(c *gin.Context) {
// Check online status by pinging MariaDB
isOnline := h.checkOnline()
// Get sync times
lastPricelistSync := h.localDB.GetLastSyncTime()
// Get error count (only changes with LastError != "")
errorCount := int(h.localDB.CountErroredChanges())
// Get recent errors (last 10)
changes, err := h.localDB.GetPendingChanges()
if err != nil {
slog.Error("failed to get pending changes for sync info", "error", err)
// Even if we can't get changes, we can still return the error count
c.JSON(http.StatusOK, SyncInfoResponse{
LastSyncAt: lastPricelistSync,
IsOnline: isOnline,
ErrorCount: errorCount,
Errors: []SyncError{}, // Return empty errors list
})
return
}
var errors []SyncError
for _, change := range changes {
// Check if there's a last error and it's not empty
if change.LastError != "" {
errors = append(errors, SyncError{
Timestamp: change.CreatedAt,
Message: change.LastError,
})
}
}
// Limit to last 10 errors
if len(errors) > 10 {
errors = errors[:10]
}
c.JSON(http.StatusOK, SyncInfoResponse{
LastSyncAt: lastPricelistSync,
IsOnline: isOnline,
ErrorCount: errorCount,
Errors: errors,
})
}
// SyncStatusPartial renders the sync status partial for htmx // SyncStatusPartial renders the sync status partial for htmx
// GET /partials/sync-status // GET /partials/sync-status
func (h *SyncHandler) SyncStatusPartial(c *gin.Context) { func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {

View File

@@ -2,12 +2,14 @@ package handlers
import ( import (
"html/template" "html/template"
"os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"github.com/gin-gonic/gin" qfassets "git.mchus.pro/mchus/quoteforge"
"git.mchus.pro/mchus/quoteforge/internal/repository" "git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services" "git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/gin-gonic/gin"
) )
type WebHandler struct { type WebHandler struct {
@@ -59,12 +61,26 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
templates := make(map[string]*template.Template) templates := make(map[string]*template.Template)
basePath := filepath.Join(templatesPath, "base.html") basePath := filepath.Join(templatesPath, "base.html")
useDisk := false
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
useDisk = true
}
// Load each page template with base // 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 { for _, page := range simplePages {
pagePath := filepath.Join(templatesPath, page) pagePath := filepath.Join(templatesPath, page)
tmpl, err := template.New("").Funcs(funcMap).ParseFiles(basePath, pagePath) var tmpl *template.Template
var err error
if useDisk {
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(basePath, pagePath)
} else {
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
qfassets.TemplatesFS,
"web/templates/base.html",
"web/templates/"+page,
)
}
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -74,7 +90,18 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
// Index page needs components_list.html as well // Index page needs components_list.html as well
indexPath := filepath.Join(templatesPath, "index.html") indexPath := filepath.Join(templatesPath, "index.html")
componentsListPath := filepath.Join(templatesPath, "components_list.html") componentsListPath := filepath.Join(templatesPath, "components_list.html")
indexTmpl, err := template.New("").Funcs(funcMap).ParseFiles(basePath, indexPath, componentsListPath) var indexTmpl *template.Template
var err error
if useDisk {
indexTmpl, err = template.New("").Funcs(funcMap).ParseFiles(basePath, indexPath, componentsListPath)
} else {
indexTmpl, err = template.New("").Funcs(funcMap).ParseFS(
qfassets.TemplatesFS,
"web/templates/base.html",
"web/templates/index.html",
"web/templates/components_list.html",
)
}
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -84,7 +111,16 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
partials := []string{"components_list.html"} partials := []string{"components_list.html"}
for _, partial := range partials { for _, partial := range partials {
partialPath := filepath.Join(templatesPath, partial) partialPath := filepath.Join(templatesPath, partial)
tmpl, err := template.New("").Funcs(funcMap).ParseFiles(partialPath) var tmpl *template.Template
var err error
if useDisk {
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(partialPath)
} else {
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
qfassets.TemplatesFS,
"web/templates/"+partial,
)
}
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -150,6 +186,17 @@ func (h *WebHandler) Configs(c *gin.Context) {
h.render(c, "configs.html", gin.H{"ActivePage": "configs"}) 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) { func (h *WebHandler) AdminPricing(c *gin.Context) {
h.render(c, "admin_pricing.html", gin.H{"ActivePage": "admin"}) h.render(c, "admin_pricing.html", gin.H{"ActivePage": "admin"})
} }

View File

@@ -9,6 +9,13 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// ComponentFilter for searching with filters
type ComponentFilter struct {
Category string
Search string
HasPrice bool
}
// ComponentSyncResult contains statistics from component sync // ComponentSyncResult contains statistics from component sync
type ComponentSyncResult struct { type ComponentSyncResult struct {
TotalSynced int TotalSynced int
@@ -196,6 +203,44 @@ func (l *LocalDB) SearchLocalComponentsByCategory(category string, query string,
return components, err return components, err
} }
// ListComponents returns components with filtering and pagination
func (l *LocalDB) ListComponents(filter ComponentFilter, offset, limit int) ([]LocalComponent, int64, error) {
db := l.db
// Apply category filter
if filter.Category != "" {
db = db.Where("LOWER(category) = ?", strings.ToLower(filter.Category))
}
// Apply search filter
if filter.Search != "" {
searchPattern := "%" + strings.ToLower(filter.Search) + "%"
db = db.Where(
"LOWER(lot_name) LIKE ? OR LOWER(lot_description) LIKE ? OR LOWER(category) LIKE ? OR LOWER(model) LIKE ?",
searchPattern, searchPattern, searchPattern, searchPattern,
)
}
// Apply price filter
if filter.HasPrice {
db = db.Where("current_price IS NOT NULL")
}
// Get total count
var total int64
if err := db.Model(&LocalComponent{}).Count(&total).Error; err != nil {
return nil, 0, err
}
// Apply pagination and get results
var components []LocalComponent
if err := db.Order("lot_name").Offset(offset).Limit(limit).Find(&components).Error; err != nil {
return nil, 0, err
}
return components, total, nil
}
// GetLocalComponent returns a single component by lot_name // GetLocalComponent returns a single component by lot_name
func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) { func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) {
var component LocalComponent var component LocalComponent
@@ -266,3 +311,100 @@ func (l *LocalDB) NeedComponentSync(maxAgeHours int) bool {
} }
return time.Since(*syncTime).Hours() > float64(maxAgeHours) return time.Since(*syncTime).Hours() > float64(maxAgeHours)
} }
// UpdateComponentPricesFromPricelist updates current_price in local_components from pricelist items
// This allows offline price updates using synced pricelists without MariaDB connection
func (l *LocalDB) UpdateComponentPricesFromPricelist(pricelistID uint) (int, error) {
// Get all items from the specified pricelist
var items []LocalPricelistItem
if err := l.db.Where("pricelist_id = ?", pricelistID).Find(&items).Error; err != nil {
return 0, fmt.Errorf("fetching pricelist items: %w", err)
}
if len(items) == 0 {
slog.Warn("no items found in pricelist", "pricelist_id", pricelistID)
return 0, nil
}
// Update current_price for each component
updated := 0
err := l.db.Transaction(func(tx *gorm.DB) error {
for _, item := range items {
result := tx.Model(&LocalComponent{}).
Where("lot_name = ?", item.LotName).
Update("current_price", item.Price)
if result.Error != nil {
return fmt.Errorf("updating price for %s: %w", item.LotName, result.Error)
}
if result.RowsAffected > 0 {
updated++
}
}
return nil
})
if err != nil {
return 0, err
}
slog.Info("updated component prices from pricelist",
"pricelist_id", pricelistID,
"total_items", len(items),
"updated_components", updated)
return updated, nil
}
// EnsureComponentPricesFromPricelists loads prices from the latest pricelist into local_components
// if no components exist or all current prices are NULL
func (l *LocalDB) EnsureComponentPricesFromPricelists() error {
// Check if we have any components with prices
var count int64
if err := l.db.Model(&LocalComponent{}).Where("current_price IS NOT NULL").Count(&count).Error; err != nil {
return fmt.Errorf("checking component prices: %w", err)
}
// If we have components with prices, don't load from pricelists
if count > 0 {
return nil
}
// Check if we have any components at all
var totalComponents int64
if err := l.db.Model(&LocalComponent{}).Count(&totalComponents).Error; err != nil {
return fmt.Errorf("counting components: %w", err)
}
// If we have no components, we need to load them from pricelists
if totalComponents == 0 {
slog.Info("no components found in local database, loading from latest pricelist")
// This would typically be called from the sync service or setup process
// For now, we'll just return nil to indicate no action needed
return nil
}
// If we have components but no prices, we should load prices from pricelists
// Find the latest pricelist
var latestPricelist LocalPricelist
if err := l.db.Order("created_at DESC").First(&latestPricelist).Error; err != nil {
if err == gorm.ErrRecordNotFound {
slog.Warn("no pricelists found in local database")
return nil
}
return fmt.Errorf("finding latest pricelist: %w", err)
}
// Update prices from the latest pricelist
updated, err := l.UpdateComponentPricesFromPricelist(latestPricelist.ID)
if err != nil {
return fmt.Errorf("updating component prices from pricelist: %w", err)
}
slog.Info("loaded component prices from latest pricelist",
"pricelist_id", latestPricelist.ID,
"updated_components", updated)
return nil
}

View File

@@ -19,6 +19,8 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
local := &LocalConfiguration{ local := &LocalConfiguration{
UUID: cfg.UUID, UUID: cfg.UUID,
ProjectUUID: cfg.ProjectUUID,
IsActive: true,
Name: cfg.Name, Name: cfg.Name,
Items: items, Items: items,
TotalPrice: cfg.TotalPrice, TotalPrice: cfg.TotalPrice,
@@ -30,7 +32,12 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
CreatedAt: cfg.CreatedAt, CreatedAt: cfg.CreatedAt,
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
SyncStatus: "pending", SyncStatus: "pending",
OriginalUserID: cfg.UserID, OriginalUserID: derefUint(cfg.UserID),
OriginalUsername: cfg.OwnerUsername,
}
if local.OriginalUsername == "" && cfg.User != nil {
local.OriginalUsername = cfg.User.Username
} }
if cfg.ID > 0 { if cfg.ID > 0 {
@@ -54,7 +61,8 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
cfg := &models.Configuration{ cfg := &models.Configuration{
UUID: local.UUID, UUID: local.UUID,
UserID: local.OriginalUserID, OwnerUsername: local.OriginalUsername,
ProjectUUID: local.ProjectUUID,
Name: local.Name, Name: local.Name,
Items: items, Items: items,
TotalPrice: local.TotalPrice, TotalPrice: local.TotalPrice,
@@ -69,10 +77,55 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
if local.ServerID != nil { if local.ServerID != nil {
cfg.ID = *local.ServerID cfg.ID = *local.ServerID
} }
if local.OriginalUserID != 0 {
userID := local.OriginalUserID
cfg.UserID = &userID
}
return cfg 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,
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,
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 // PricelistToLocal converts models.Pricelist to LocalPricelist
func PricelistToLocal(pl *models.Pricelist) *LocalPricelist { func PricelistToLocal(pl *models.Pricelist) *LocalPricelist {
name := pl.Notification name := pl.Notification

View File

@@ -0,0 +1,72 @@
package localdb
import (
"path/filepath"
"testing"
)
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")
}
}

View File

@@ -1,13 +1,17 @@
package localdb package localdb
import ( import (
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
uuidpkg "github.com/google/uuid"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
) )
@@ -51,7 +55,9 @@ func New(dbPath string) (*LocalDB, error) {
// Auto-migrate all local tables // Auto-migrate all local tables
if err := db.AutoMigrate( if err := db.AutoMigrate(
&ConnectionSettings{}, &ConnectionSettings{},
&LocalProject{},
&LocalConfiguration{}, &LocalConfiguration{},
&LocalConfigurationVersion{},
&LocalPricelist{}, &LocalPricelist{},
&LocalPricelistItem{}, &LocalPricelistItem{},
&LocalComponent{}, &LocalComponent{},
@@ -60,6 +66,9 @@ func New(dbPath string) (*LocalDB, error) {
); err != nil { ); err != nil {
return nil, fmt.Errorf("migrating sqlite database: %w", err) 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) slog.Info("local SQLite database initialized", "path", dbPath)
@@ -132,7 +141,11 @@ func (l *LocalDB) GetDSN() (string, error) {
return "", err return "", err
} }
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local", // 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.User,
settings.PasswordEncrypted, // Contains decrypted password after GetSettings settings.PasswordEncrypted, // Contains decrypted password after GetSettings
settings.Host, settings.Host,
@@ -168,6 +181,216 @@ func (l *LocalDB) GetDBUser() string {
// Configuration methods // 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 // SaveConfiguration saves a configuration to local SQLite
func (l *LocalDB) SaveConfiguration(config *LocalConfiguration) error { func (l *LocalDB) SaveConfiguration(config *LocalConfiguration) error {
return l.db.Save(config).Error return l.db.Save(config).Error
@@ -189,7 +412,60 @@ func (l *LocalDB) GetConfigurationByUUID(uuid string) (*LocalConfiguration, erro
// DeleteConfiguration deletes a configuration by UUID // DeleteConfiguration deletes a configuration by UUID
func (l *LocalDB) DeleteConfiguration(uuid string) error { 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 // CountConfigurations returns the number of local configurations
@@ -396,6 +672,13 @@ func (l *LocalDB) CountPendingChangesByType(entityType string) int64 {
return count return count
} }
// CountErroredChanges returns the number of pending changes with errors
func (l *LocalDB) CountErroredChanges() int64 {
var count int64
l.db.Model(&PendingChange{}).Where("last_error != ?", "").Count(&count)
return count
}
// MarkChangesSynced marks multiple pending changes as synced by deleting them // MarkChangesSynced marks multiple pending changes as synced by deleting them
func (l *LocalDB) MarkChangesSynced(ids []int64) error { func (l *LocalDB) MarkChangesSynced(ids []int64) error {
if len(ids) == 0 { if len(ids) == 0 {
@@ -404,6 +687,16 @@ func (l *LocalDB) MarkChangesSynced(ids []int64) error {
return l.db.Where("id IN ?", ids).Delete(&PendingChange{}).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) // GetPendingCount returns the total number of pending changes (alias for CountPendingChanges)
func (l *LocalDB) GetPendingCount() int64 { func (l *LocalDB) GetPendingCount() int64 {
return l.CountPendingChanges() 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,200 @@
package localdb
import (
"errors"
"fmt"
"log/slog"
"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,
},
}
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 chooseNonZeroTime(candidate time.Time, fallback time.Time) time.Time {
if candidate.IsZero() {
return fallback
}
return candidate
}

View File

@@ -62,6 +62,9 @@ type LocalConfiguration struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"` ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UUID string `gorm:"uniqueIndex;not null" json:"uuid"` UUID string `gorm:"uniqueIndex;not null" json:"uuid"`
ServerID *uint `json:"server_id"` // ID on MariaDB server, NULL if local only 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"` Name string `gorm:"not null" json:"name"`
Items LocalConfigItems `gorm:"type:text" json:"items"` // JSON stored as text in SQLite Items LocalConfigItems `gorm:"type:text" json:"items"` // JSON stored as text in SQLite
TotalPrice *float64 `json:"total_price"` TotalPrice *float64 `json:"total_price"`
@@ -75,12 +78,50 @@ type LocalConfiguration struct {
SyncedAt *time.Time `json:"synced_at"` SyncedAt *time.Time `json:"synced_at"`
SyncStatus string `gorm:"default:'local'" json:"sync_status"` // 'local', 'synced', 'modified' SyncStatus string `gorm:"default:'local'" json:"sync_status"` // 'local', 'synced', 'modified'
OriginalUserID uint `json:"original_user_id"` // UserID from MariaDB for reference 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 { func (LocalConfiguration) TableName() string {
return "local_configurations" 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"`
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 // LocalPricelist stores cached pricelists from server
type LocalPricelist struct { type LocalPricelist struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"` ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
@@ -127,7 +168,7 @@ type PendingChange struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
EntityType string `gorm:"not null;index" json:"entity_type"` // "configuration", "project", "specification" EntityType string `gorm:"not null;index" json:"entity_type"` // "configuration", "project", "specification"
EntityUUID string `gorm:"not null;index" json:"entity_uuid"` 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 Payload string `gorm:"type:text" json:"payload"` // JSON snapshot of the entity
CreatedAt time.Time `gorm:"not null" json:"created_at"` CreatedAt time.Time `gorm:"not null" json:"created_at"`
Attempts int `gorm:"default:0" json:"attempts"` // Retry count for sync Attempts int `gorm:"default:0" json:"attempts"` // Retry count for sync

View File

@@ -0,0 +1,81 @@
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,
"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"`
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,
PriceUpdatedAt: snapshot.PriceUpdatedAt,
OriginalUserID: snapshot.OriginalUserID,
OriginalUsername: snapshot.OriginalUsername,
}, nil
}

View File

@@ -4,9 +4,9 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/gin-gonic/gin"
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/services" "git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/gin-gonic/gin"
) )
const ( const (
@@ -99,3 +99,12 @@ func GetUserID(c *gin.Context) uint {
} }
return claims.UserID 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

@@ -4,17 +4,17 @@ import (
"log/slog" "log/slog"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.mchus.pro/mchus/quoteforge/internal/db"
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
"gorm.io/gorm"
) )
// OfflineDetector creates middleware that detects offline mode // OfflineDetector creates middleware that detects offline mode
// Sets context values: // Sets context values:
// - "is_offline" (bool) - true if MariaDB is unavailable // - "is_offline" (bool) - true if MariaDB is unavailable
// - "localdb" (*localdb.LocalDB) - local database instance for fallback // - "localdb" (*localdb.LocalDB) - local database instance for fallback
func OfflineDetector(mariaDB *gorm.DB, local *localdb.LocalDB) gin.HandlerFunc { func OfflineDetector(connMgr *db.ConnectionManager, local *localdb.LocalDB) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
isOffline := !checkMariaDBOnline(mariaDB) isOffline := !connMgr.IsOnline()
// Set context values for handlers // Set context values for handlers
c.Set("is_offline", isOffline) c.Set("is_offline", isOffline)
@@ -27,17 +27,3 @@ func OfflineDetector(mariaDB *gorm.DB, local *localdb.LocalDB) gin.HandlerFunc {
c.Next() c.Next()
} }
} }
// checkMariaDBOnline checks if MariaDB is accessible
func checkMariaDBOnline(mariaDB *gorm.DB) bool {
sqlDB, err := mariaDB.DB()
if err != nil {
return false
}
if err := sqlDB.Ping(); err != nil {
return false
}
return true
}

View File

@@ -42,7 +42,10 @@ func (c ConfigItems) Total() float64 {
type Configuration struct { type Configuration struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"` ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"` UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
UserID uint `gorm:"not null" json:"user_id"` 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"` Name string `gorm:"size:200;not null" json:"name"`
Items ConfigItems `gorm:"type:json;not null" json:"items"` Items ConfigItems `gorm:"type:json;not null" json:"items"`
TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"` TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"`

View File

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

View File

@@ -0,0 +1,18 @@
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"`
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,159 @@
package models
import (
"bufio"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"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"
}
// 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)
}
entries, err := os.ReadDir(migrationsDir)
if err != nil {
return 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)
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
}
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
}

View File

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

View File

@@ -1,7 +1,9 @@
package repository package repository
import ( import (
"errors"
"fmt" "fmt"
"strconv"
"strings" "strings"
"time" "time"
@@ -29,6 +31,34 @@ func (r *PricelistRepository) List(offset, limit int) ([]models.PricelistSummary
return nil, 0, fmt.Errorf("listing pricelists: %w", err) 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) {
var total int64
if err := r.db.Model(&models.Pricelist{}).Where("is_active = ?", true).Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("counting active pricelists: %w", err)
}
var pricelists []models.Pricelist
if err := r.db.Where("is_active = ?", true).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 // Get item counts for each pricelist
summaries := make([]models.PricelistSummary, len(pricelists)) summaries := make([]models.PricelistSummary, len(pricelists))
for i, pl := range pricelists { for i, pl := range pricelists {
@@ -48,7 +78,7 @@ func (r *PricelistRepository) List(offset, limit int) ([]models.PricelistSummary
} }
} }
return summaries, total, nil return summaries
} }
// GetByID returns a pricelist by ID // GetByID returns a pricelist by ID
@@ -182,14 +212,31 @@ func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, sear
func (r *PricelistRepository) GenerateVersion() (string, error) { func (r *PricelistRepository) GenerateVersion() (string, error) {
today := time.Now().Format("2006-01-02") today := time.Now().Format("2006-01-02")
var count int64 var last models.Pricelist
if err := r.db.Model(&models.Pricelist{}). err := r.db.Model(&models.Pricelist{}).
Where("version LIKE ?", today+"%"). Select("version").
Count(&count).Error; err != nil { Where("version LIKE ?", today+"-%").
return "", fmt.Errorf("counting today's pricelists: %w", err) 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
} }
// CanWrite checks if the current database user has INSERT permission on qt_pricelists // CanWrite checks if the current database user has INSERT permission on qt_pricelists

View File

@@ -0,0 +1,64 @@
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.GenerateVersion()
if err != nil {
t.Fatalf("GenerateVersion 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{
{Version: fmt.Sprintf("%s-001", today), CreatedBy: "test", IsActive: true},
{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.GenerateVersion()
if err != nil {
t.Fatalf("GenerateVersion returned error: %v", err)
}
want := fmt.Sprintf("%s-004", 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,169 @@
package repository
import (
"git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/gorm"
)
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) 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 // Configurations
SaveConfiguration(cfg *models.Configuration) error SaveConfiguration(cfg *models.Configuration) error
GetConfigurations(userID uint) ([]models.Configuration, error) GetConfigurations(ownerUsername string) ([]models.Configuration, error)
GetConfigurationByUUID(uuid string) (*models.Configuration, error) GetConfigurationByUUID(uuid string) (*models.Configuration, error)
DeleteConfiguration(uuid string) error DeleteConfiguration(uuid string) error
@@ -169,6 +169,7 @@ func (r *UnifiedRepo) SaveConfiguration(cfg *models.Configuration) error {
CreatedAt: cfg.CreatedAt, CreatedAt: cfg.CreatedAt,
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
SyncStatus: "pending", SyncStatus: "pending",
OriginalUsername: cfg.OwnerUsername,
} }
// Convert items // Convert items
@@ -196,10 +197,10 @@ func (r *UnifiedRepo) SaveConfiguration(cfg *models.Configuration) error {
} }
// GetConfigurations returns all configurations for a user // 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 { if r.isOnline {
repo := NewConfigurationRepository(r.mariaDB) repo := NewConfigurationRepository(r.mariaDB)
configs, _, err := repo.ListByUser(userID, 0, 1000) configs, _, err := repo.ListByUser(ownerUsername, 0, 1000)
return configs, err return configs, err
} }
@@ -223,6 +224,7 @@ func (r *UnifiedRepo) GetConfigurations(userID uint) ([]models.Configuration, er
result[i] = models.Configuration{ result[i] = models.Configuration{
UUID: lc.UUID, UUID: lc.UUID,
OwnerUsername: lc.OriginalUsername,
Name: lc.Name, Name: lc.Name,
Items: items, Items: items,
TotalPrice: lc.TotalPrice, TotalPrice: lc.TotalPrice,

View File

@@ -1,6 +1,7 @@
package services package services
import ( import (
"fmt"
"strings" "strings"
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
@@ -59,6 +60,17 @@ type ComponentView struct {
} }
func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage int) (*ComponentListResult, error) { func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage int) (*ComponentListResult, error) {
// If no database connection (offline mode), return empty list
// Components should be loaded via /api/sync/components first
if s.componentRepo == nil {
return &ComponentListResult{
Components: []ComponentView{},
Total: 0,
Page: page,
PerPage: perPage,
}, nil
}
if page < 1 { if page < 1 {
page = 1 page = 1
} }
@@ -106,6 +118,11 @@ func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage
} }
func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error) { func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error) {
// If no database connection (offline mode), return error
if s.componentRepo == nil {
return nil, fmt.Errorf("offline mode: component data not available")
}
c, err := s.componentRepo.GetByLotName(lotName) c, err := s.componentRepo.GetByLotName(lotName)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -135,11 +152,20 @@ func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error)
} }
func (s *ComponentService) GetCategories() ([]models.Category, error) { func (s *ComponentService) GetCategories() ([]models.Category, error) {
// If no database connection (offline mode), return default categories
if s.categoryRepo == nil {
return models.DefaultCategories, nil
}
return s.categoryRepo.GetAll() return s.categoryRepo.GetAll()
} }
// ImportFromLot creates metadata entries for lots that don't have them // ImportFromLot creates metadata entries for lots that don't have them
func (s *ComponentService) ImportFromLot() (int, error) { func (s *ComponentService) ImportFromLot() (int, error) {
// If no database connection (offline mode), return error
if s.componentRepo == nil || s.categoryRepo == nil {
return 0, fmt.Errorf("offline mode: import not available")
}
lots, err := s.componentRepo.GetLotsWithoutMetadata() lots, err := s.componentRepo.GetLotsWithoutMetadata()
if err != nil { if err != nil {
return 0, err return 0, err

View File

@@ -4,9 +4,9 @@ import (
"errors" "errors"
"time" "time"
"github.com/google/uuid"
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository" "git.mchus.pro/mchus/quoteforge/internal/repository"
"github.com/google/uuid"
) )
var ( var (
@@ -17,22 +17,25 @@ var (
// ConfigurationGetter is an interface for services that can retrieve configurations // ConfigurationGetter is an interface for services that can retrieve configurations
// Used by handlers to work with both ConfigurationService and LocalConfigurationService // Used by handlers to work with both ConfigurationService and LocalConfigurationService
type ConfigurationGetter interface { type ConfigurationGetter interface {
GetByUUID(uuid string, userID uint) (*models.Configuration, error) GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error)
} }
type ConfigurationService struct { type ConfigurationService struct {
configRepo *repository.ConfigurationRepository configRepo *repository.ConfigurationRepository
projectRepo *repository.ProjectRepository
componentRepo *repository.ComponentRepository componentRepo *repository.ComponentRepository
quoteService *QuoteService quoteService *QuoteService
} }
func NewConfigurationService( func NewConfigurationService(
configRepo *repository.ConfigurationRepository, configRepo *repository.ConfigurationRepository,
projectRepo *repository.ProjectRepository,
componentRepo *repository.ComponentRepository, componentRepo *repository.ComponentRepository,
quoteService *QuoteService, quoteService *QuoteService,
) *ConfigurationService { ) *ConfigurationService {
return &ConfigurationService{ return &ConfigurationService{
configRepo: configRepo, configRepo: configRepo,
projectRepo: projectRepo,
componentRepo: componentRepo, componentRepo: componentRepo,
quoteService: quoteService, quoteService: quoteService,
} }
@@ -41,13 +44,19 @@ func NewConfigurationService(
type CreateConfigRequest struct { type CreateConfigRequest struct {
Name string `json:"name"` Name string `json:"name"`
Items models.ConfigItems `json:"items"` Items models.ConfigItems `json:"items"`
ProjectUUID *string `json:"project_uuid,omitempty"`
CustomPrice *float64 `json:"custom_price"` CustomPrice *float64 `json:"custom_price"`
Notes string `json:"notes"` Notes string `json:"notes"`
IsTemplate bool `json:"is_template"` IsTemplate bool `json:"is_template"`
ServerCount int `json:"server_count"` ServerCount int `json:"server_count"`
} }
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
}
total := req.Items.Total() total := req.Items.Total()
// If server count is greater than 1, multiply the total by server count // If server count is greater than 1, multiply the total by server count
@@ -57,7 +66,8 @@ func (s *ConfigurationService) Create(userID uint, req *CreateConfigRequest) (*m
config := &models.Configuration{ config := &models.Configuration{
UUID: uuid.New().String(), UUID: uuid.New().String(),
UserID: userID, OwnerUsername: ownerUsername,
ProjectUUID: projectUUID,
Name: req.Name, Name: req.Name,
Items: req.Items, Items: req.Items,
TotalPrice: &total, TotalPrice: &total,
@@ -77,30 +87,35 @@ func (s *ConfigurationService) Create(userID uint, req *CreateConfigRequest) (*m
return config, nil 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) config, err := s.configRepo.GetByUUID(uuid)
if err != nil { if err != nil {
return nil, ErrConfigNotFound return nil, ErrConfigNotFound
} }
// Allow access if user owns config or it's a template // 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 nil, ErrConfigForbidden
} }
return config, nil 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) config, err := s.configRepo.GetByUUID(uuid)
if err != nil { if err != nil {
return nil, ErrConfigNotFound return nil, ErrConfigNotFound
} }
if config.UserID != userID { if !s.isOwner(config, ownerUsername) {
return nil, ErrConfigForbidden return nil, ErrConfigForbidden
} }
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
if err != nil {
return nil, err
}
total := req.Items.Total() total := req.Items.Total()
// If server count is greater than 1, multiply the total by server count // If server count is greater than 1, multiply the total by server count
@@ -109,6 +124,7 @@ func (s *ConfigurationService) Update(uuid string, userID uint, req *CreateConfi
} }
config.Name = req.Name config.Name = req.Name
config.ProjectUUID = projectUUID
config.Items = req.Items config.Items = req.Items
config.TotalPrice = &total config.TotalPrice = &total
config.CustomPrice = req.CustomPrice config.CustomPrice = req.CustomPrice
@@ -123,26 +139,26 @@ func (s *ConfigurationService) Update(uuid string, userID uint, req *CreateConfi
return config, nil 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) config, err := s.configRepo.GetByUUID(uuid)
if err != nil { if err != nil {
return ErrConfigNotFound return ErrConfigNotFound
} }
if config.UserID != userID { if !s.isOwner(config, ownerUsername) {
return ErrConfigForbidden return ErrConfigForbidden
} }
return s.configRepo.Delete(config.ID) 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) config, err := s.configRepo.GetByUUID(uuid)
if err != nil { if err != nil {
return nil, ErrConfigNotFound return nil, ErrConfigNotFound
} }
if config.UserID != userID { if !s.isOwner(config, ownerUsername) {
return nil, ErrConfigForbidden return nil, ErrConfigForbidden
} }
@@ -155,11 +171,22 @@ func (s *ConfigurationService) Rename(uuid string, userID uint, newName string)
return config, nil return config, nil
} }
func (s *ConfigurationService) Clone(configUUID string, userID uint, newName string) (*models.Configuration, error) { func (s *ConfigurationService) Clone(configUUID string, ownerUsername string, newName string) (*models.Configuration, error) {
original, err := s.GetByUUID(configUUID, userID) 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 { if err != nil {
return nil, err 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 // Create copy with new UUID and name
total := original.Items.Total() total := original.Items.Total()
@@ -171,7 +198,8 @@ func (s *ConfigurationService) Clone(configUUID string, userID uint, newName str
clone := &models.Configuration{ clone := &models.Configuration{
UUID: uuid.New().String(), UUID: uuid.New().String(),
UserID: userID, OwnerUsername: ownerUsername,
ProjectUUID: resolvedProjectUUID,
Name: newName, Name: newName,
Items: original.Items, Items: original.Items,
TotalPrice: &total, TotalPrice: &total,
@@ -188,7 +216,7 @@ func (s *ConfigurationService) Clone(configUUID string, userID uint, newName str
return clone, nil 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 { if page < 1 {
page = 1 page = 1
} }
@@ -197,7 +225,7 @@ func (s *ConfigurationService) ListByUser(userID uint, page, perPage int) ([]mod
} }
offset := (page - 1) * perPage 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) // ListAll returns all configurations without user filter (for use when auth is disabled)
@@ -229,12 +257,18 @@ func (s *ConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigReques
return nil, ErrConfigNotFound return nil, ErrConfigNotFound
} }
projectUUID, err := s.resolveProjectUUID(config.OwnerUsername, req.ProjectUUID)
if err != nil {
return nil, err
}
total := req.Items.Total() total := req.Items.Total()
if req.ServerCount > 1 { if req.ServerCount > 1 {
total *= float64(req.ServerCount) total *= float64(req.ServerCount)
} }
config.Name = req.Name config.Name = req.Name
config.ProjectUUID = projectUUID
config.Items = req.Items config.Items = req.Items
config.TotalPrice = &total config.TotalPrice = &total
config.CustomPrice = req.CustomPrice config.CustomPrice = req.CustomPrice
@@ -274,11 +308,22 @@ func (s *ConfigurationService) RenameNoAuth(uuid string, newName string) (*model
} }
// CloneNoAuth clones configuration without ownership check // 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) original, err := s.configRepo.GetByUUID(configUUID)
if err != nil { if err != nil {
return nil, ErrConfigNotFound 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() total := original.Items.Total()
if original.ServerCount > 1 { if original.ServerCount > 1 {
@@ -287,7 +332,8 @@ func (s *ConfigurationService) CloneNoAuth(configUUID string, newName string, us
clone := &models.Configuration{ clone := &models.Configuration{
UUID: uuid.New().String(), UUID: uuid.New().String(),
UserID: userID, // Use provided user ID OwnerUsername: ownerUsername,
ProjectUUID: resolvedProjectUUID,
Name: newName, Name: newName,
Items: original.Items, Items: original.Items,
TotalPrice: &total, TotalPrice: &total,
@@ -304,6 +350,26 @@ func (s *ConfigurationService) CloneNoAuth(configUUID string, newName string, us
return clone, nil 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
}
// RefreshPricesNoAuth refreshes prices without ownership check // RefreshPricesNoAuth refreshes prices without ownership check
func (s *ConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) { func (s *ConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) {
config, err := s.configRepo.GetByUUID(uuid) config, err := s.configRepo.GetByUUID(uuid)
@@ -356,13 +422,13 @@ func (s *ConfigurationService) ListTemplates(page, perPage int) ([]models.Config
} }
// RefreshPrices updates all component prices in the configuration with current prices // 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) config, err := s.configRepo.GetByUUID(uuid)
if err != nil { if err != nil {
return nil, ErrConfigNotFound return nil, ErrConfigNotFound
} }
if config.UserID != userID { if !s.isOwner(config, ownerUsername) {
return nil, ErrConfigForbidden return nil, ErrConfigForbidden
} }
@@ -407,6 +473,19 @@ func (s *ConfigurationService) RefreshPrices(uuid string, userID uint) (*models.
return config, nil 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 // // Export configuration as JSON
// type ConfigExport struct { // type ConfigExport struct {
// Name string `json:"name"` // Name string `json:"name"`

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,357 @@
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 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,8 +1,10 @@
package pricelist package pricelist
import ( import (
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"strings"
"time" "time"
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
@@ -26,14 +28,20 @@ func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo
// CreateFromCurrentPrices creates a new pricelist by taking a snapshot of current prices // CreateFromCurrentPrices creates a new pricelist by taking a snapshot of current prices
func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist, error) { func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist, error) {
if s.repo == nil || s.db == nil {
return nil, fmt.Errorf("offline mode: cannot create pricelists")
}
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.GenerateVersion() version, err := s.repo.GenerateVersion()
if err != nil { if err != nil {
return nil, fmt.Errorf("generating version: %w", err) return nil, fmt.Errorf("generating version: %w", err)
} }
expiresAt := time.Now().AddDate(1, 0, 0) // +1 year pricelist = &models.Pricelist{
pricelist := &models.Pricelist{
Version: version, Version: version,
CreatedBy: createdBy, CreatedBy: createdBy,
IsActive: true, IsActive: true,
@@ -41,8 +49,19 @@ func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist,
} }
if err := s.repo.Create(pricelist); err != nil { 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) return nil, fmt.Errorf("creating pricelist: %w", err)
} }
break
}
// Get all components with prices from qt_lot_metadata // Get all components with prices from qt_lot_metadata
var metadata []models.LotMetadata var metadata []models.LotMetadata
@@ -86,8 +105,21 @@ func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist,
return pricelist, nil 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_version")
}
// List returns pricelists with pagination // List returns pricelists with pagination
func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, error) { func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, error) {
// If no database connection (offline mode), return empty list
if s.repo == nil {
return []models.PricelistSummary{}, 0, nil
}
if page < 1 { if page < 1 {
page = 1 page = 1
} }
@@ -100,11 +132,17 @@ func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, err
// GetByID returns a pricelist by ID // GetByID returns a pricelist by ID
func (s *Service) GetByID(id uint) (*models.Pricelist, error) { func (s *Service) GetByID(id uint) (*models.Pricelist, error) {
if s.repo == nil {
return nil, fmt.Errorf("offline mode: pricelist service not available")
}
return s.repo.GetByID(id) return s.repo.GetByID(id)
} }
// GetItems returns pricelist items with pagination // GetItems returns pricelist items with pagination
func (s *Service) GetItems(pricelistID uint, page, perPage int, search string) ([]models.PricelistItem, int64, error) { func (s *Service) GetItems(pricelistID uint, page, perPage int, search string) ([]models.PricelistItem, int64, error) {
if s.repo == nil {
return []models.PricelistItem{}, 0, nil
}
if page < 1 { if page < 1 {
page = 1 page = 1
} }
@@ -117,26 +155,42 @@ func (s *Service) GetItems(pricelistID uint, page, perPage int, search string) (
// Delete deletes a pricelist by ID // Delete deletes a pricelist by ID
func (s *Service) Delete(id uint) error { func (s *Service) Delete(id uint) error {
if s.repo == nil {
return fmt.Errorf("offline mode: cannot delete pricelists")
}
return s.repo.Delete(id) return s.repo.Delete(id)
} }
// CanWrite returns true if the user can create pricelists // CanWrite returns true if the user can create pricelists
func (s *Service) CanWrite() bool { func (s *Service) CanWrite() bool {
if s.repo == nil {
return false
}
return s.repo.CanWrite() return s.repo.CanWrite()
} }
// CanWriteDebug returns write permission status with debug info // CanWriteDebug returns write permission status with debug info
func (s *Service) CanWriteDebug() (bool, string) { func (s *Service) CanWriteDebug() (bool, string) {
if s.repo == nil {
return false, "offline mode"
}
return s.repo.CanWriteDebug() return s.repo.CanWriteDebug()
} }
// GetLatestActive returns the most recent active pricelist // GetLatestActive returns the most recent active pricelist
func (s *Service) GetLatestActive() (*models.Pricelist, error) { func (s *Service) GetLatestActive() (*models.Pricelist, error) {
if s.repo == nil {
return nil, fmt.Errorf("offline mode: pricelist service not available")
}
return s.repo.GetLatestActive() return s.repo.GetLatestActive()
} }
// CleanupExpired deletes expired and unused pricelists // CleanupExpired deletes expired and unused pricelists
func (s *Service) CleanupExpired() (int, error) { func (s *Service) CleanupExpired() (int, error) {
if s.repo == nil {
return 0, fmt.Errorf("offline mode: cleanup not available")
}
expired, err := s.repo.GetExpiredUnused() expired, err := s.repo.GetExpiredUnused()
if err != nil { if err != nil {
return 0, err return 0, err

View File

@@ -68,7 +68,7 @@ func (s *Service) CalculatePrice(lotName string, method models.PriceMethod, peri
case models.PriceMethodAverage: case models.PriceMethodAverage:
return CalculateAverage(prices), nil return CalculateAverage(prices), nil
case models.PriceMethodWeightedMedian: case models.PriceMethodWeightedMedian:
return CalculateWeightedMedian(points, s.config.DefaultPeriodDays), nil return CalculateWeightedMedian(points, periodDays), nil
case models.PriceMethodMedian: case models.PriceMethodMedian:
fallthrough fallthrough
default: default:

View File

@@ -0,0 +1,296 @@
package services
import (
"encoding/json"
"errors"
"fmt"
"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"`
}
type UpdateProjectRequest struct {
Name string `json:"name"`
}
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,
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
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 (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

@@ -61,6 +61,9 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
if len(req.Items) == 0 { if len(req.Items) == 0 {
return nil, ErrEmptyQuote return nil, ErrEmptyQuote
} }
if s.componentRepo == nil || s.pricingService == nil {
return nil, errors.New("offline mode: quote calculation not available")
}
result := &QuoteValidationResult{ result := &QuoteValidationResult{
Valid: true, Valid: true,
@@ -129,6 +132,11 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
// RecordUsage records that components were used in a quote // RecordUsage records that components were used in a quote
func (s *QuoteService) RecordUsage(items []models.ConfigItem) error { 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 { for _, item := range items {
revenue := item.UnitPrice * float64(item.Quantity) revenue := item.UnitPrice * float64(item.Quantity)
if err := s.statsRepo.IncrementUsage(item.LotName, item.Quantity, revenue); err != nil { if err := s.statsRepo.IncrementUsage(item.LotName, item.Quantity, revenue); err != nil {

View File

@@ -2,31 +2,45 @@ package sync
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"time" "time"
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
"git.mchus.pro/mchus/quoteforge/internal/db"
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository" "git.mchus.pro/mchus/quoteforge/internal/repository"
"github.com/google/uuid"
"gorm.io/gorm"
) )
var ErrOffline = errors.New("database is offline")
// Service handles synchronization between MariaDB and local SQLite // Service handles synchronization between MariaDB and local SQLite
type Service struct { type Service struct {
pricelistRepo *repository.PricelistRepository connMgr *db.ConnectionManager
configRepo *repository.ConfigurationRepository
localDB *localdb.LocalDB localDB *localdb.LocalDB
directDB *gorm.DB
} }
// NewService creates a new sync service // NewService creates a new sync service
func NewService(pricelistRepo *repository.PricelistRepository, configRepo *repository.ConfigurationRepository, localDB *localdb.LocalDB) *Service { func NewService(connMgr *db.ConnectionManager, localDB *localdb.LocalDB) *Service {
return &Service{ return &Service{
pricelistRepo: pricelistRepo, connMgr: connMgr,
configRepo: configRepo,
localDB: localDB, localDB: localDB,
} }
} }
// NewServiceWithDB creates sync service that uses a direct DB handle (used in tests).
func NewServiceWithDB(mariaDB *gorm.DB, localDB *localdb.LocalDB) *Service {
return &Service{
localDB: localDB,
directDB: mariaDB,
}
}
// SyncStatus represents the current sync status // SyncStatus represents the current sync status
type SyncStatus struct { type SyncStatus struct {
LastSyncAt *time.Time `json:"last_sync_at"` LastSyncAt *time.Time `json:"last_sync_at"`
@@ -35,14 +49,116 @@ type SyncStatus struct {
NeedsSync bool `json:"needs_sync"` NeedsSync bool `json:"needs_sync"`
} }
// ConfigImportResult represents server->local configuration import stats.
type ConfigImportResult struct {
Imported int `json:"imported"`
Updated int `json:"updated"`
Skipped int `json:"skipped"`
}
// ConfigurationChangePayload is stored in pending_changes.payload for configuration events.
// It carries version metadata so sync can push the latest snapshot and prepare for conflict resolution.
type ConfigurationChangePayload struct {
EventID string `json:"event_id"`
IdempotencyKey string `json:"idempotency_key"`
ConfigurationUUID string `json:"configuration_uuid"`
ProjectUUID *string `json:"project_uuid,omitempty"`
Operation string `json:"operation"` // create/update/rollback/deactivate/reactivate/delete
CurrentVersionID string `json:"current_version_id,omitempty"`
CurrentVersionNo int `json:"current_version_no,omitempty"`
ConflictPolicy string `json:"conflict_policy,omitempty"` // currently: last_write_wins
Snapshot models.Configuration `json:"snapshot"`
CreatedAt time.Time `json:"created_at"`
CreatedBy *string `json:"created_by,omitempty"`
}
type ProjectChangePayload struct {
EventID string `json:"event_id"`
IdempotencyKey string `json:"idempotency_key"`
ProjectUUID string `json:"project_uuid"`
Operation string `json:"operation"`
Snapshot models.Project `json:"snapshot"`
CreatedAt time.Time `json:"created_at"`
}
// ImportConfigurationsToLocal imports configurations from MariaDB into local SQLite.
// Existing local configs with pending local changes are skipped to avoid data loss.
func (s *Service) ImportConfigurationsToLocal() (*ConfigImportResult, error) {
mariaDB, err := s.getDB()
if err != nil {
return nil, ErrOffline
}
configRepo := repository.NewConfigurationRepository(mariaDB)
result := &ConfigImportResult{}
offset := 0
const limit = 200
for {
serverConfigs, _, err := configRepo.ListAll(offset, limit)
if err != nil {
return nil, fmt.Errorf("listing server configurations: %w", err)
}
if len(serverConfigs) == 0 {
break
}
for i := range serverConfigs {
cfg := serverConfigs[i]
existing, err := s.localDB.GetConfigurationByUUID(cfg.UUID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("getting local configuration %s: %w", cfg.UUID, err)
}
if existing != nil && err == nil && existing.SyncStatus == "pending" {
result.Skipped++
continue
}
if existing != nil && err == nil && !existing.IsActive {
// Keep local deactivation sticky: do not resurrect hidden entries from server pull.
result.Skipped++
continue
}
localCfg := localdb.ConfigurationToLocal(&cfg)
now := time.Now()
localCfg.SyncedAt = &now
localCfg.SyncStatus = "synced"
localCfg.UpdatedAt = now
if existing != nil && err == nil {
localCfg.ID = existing.ID
result.Updated++
} else {
result.Imported++
}
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
return nil, fmt.Errorf("saving local configuration %s: %w", cfg.UUID, err)
}
}
offset += len(serverConfigs)
}
return result, nil
}
// GetStatus returns the current sync status // GetStatus returns the current sync status
func (s *Service) GetStatus() (*SyncStatus, error) { func (s *Service) GetStatus() (*SyncStatus, error) {
lastSync := s.localDB.GetLastSyncTime() lastSync := s.localDB.GetLastSyncTime()
// Count server pricelists // Count server pricelists (only if already connected, don't reconnect)
serverPricelists, _, err := s.pricelistRepo.List(0, 1) serverCount := 0
if err != nil { connStatus := s.getConnectionStatus()
return nil, fmt.Errorf("counting server pricelists: %w", err) if connStatus.IsConnected {
if mariaDB, err := s.getDB(); err == nil && mariaDB != nil {
pricelistRepo := repository.NewPricelistRepository(mariaDB)
activeCount, err := pricelistRepo.CountActive()
if err == nil {
serverCount = int(activeCount)
}
}
} }
// Count local pricelists // Count local pricelists
@@ -52,7 +168,7 @@ func (s *Service) GetStatus() (*SyncStatus, error) {
return &SyncStatus{ return &SyncStatus{
LastSyncAt: lastSync, LastSyncAt: lastSync,
ServerPricelists: len(serverPricelists), ServerPricelists: serverCount,
LocalPricelists: int(localCount), LocalPricelists: int(localCount),
NeedsSync: needsSync, NeedsSync: needsSync,
}, nil }, nil
@@ -73,8 +189,21 @@ func (s *Service) NeedSync() (bool, error) {
return true, nil return true, nil
} }
// Check if there are new pricelists on server // Check if there are new pricelists on server (only if already connected)
latestServer, err := s.pricelistRepo.GetLatestActive() connStatus := s.getConnectionStatus()
if !connStatus.IsConnected {
// If offline, can't check server, no need to sync
return false, nil
}
mariaDB, err := s.getDB()
if err != nil {
// If offline, can't check server, no need to sync
return false, nil
}
pricelistRepo := repository.NewPricelistRepository(mariaDB)
latestServer, err := pricelistRepo.GetLatestActive()
if err != nil { if err != nil {
// If no pricelists on server, no need to sync // If no pricelists on server, no need to sync
return false, nil return false, nil
@@ -98,18 +227,33 @@ func (s *Service) NeedSync() (bool, error) {
func (s *Service) SyncPricelists() (int, error) { func (s *Service) SyncPricelists() (int, error) {
slog.Info("starting pricelist sync") slog.Info("starting pricelist sync")
// Get all active pricelists from server (up to 100) // Get database connection
serverPricelists, _, err := s.pricelistRepo.List(0, 100) mariaDB, err := s.getDB()
if err != nil { if err != nil {
return 0, fmt.Errorf("getting server pricelists: %w", err) return 0, fmt.Errorf("database not available: %w", err)
}
// Create repository
pricelistRepo := repository.NewPricelistRepository(mariaDB)
// Get active pricelists from server (up to 100)
serverPricelists, _, err := pricelistRepo.ListActive(0, 100)
if err != nil {
return 0, fmt.Errorf("getting active server pricelists: %w", err)
} }
synced := 0 synced := 0
var latestLocalID uint
var latestServerID uint
for _, pl := range serverPricelists { for _, pl := range serverPricelists {
// Check if pricelist already exists locally // Check if pricelist already exists locally
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID) existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
if existing != nil { if existing != nil {
// Already synced, skip // Already synced, track latest by server ID
if pl.ID > latestServerID {
latestServerID = pl.ID
latestLocalID = existing.ID
}
continue continue
} }
@@ -128,8 +272,30 @@ func (s *Service) SyncPricelists() (int, error) {
continue continue
} }
// Sync items for the newly created pricelist
itemCount, err := s.SyncPricelistItems(localPL.ID)
if err != nil {
slog.Warn("failed to sync pricelist items", "version", pl.Version, "error", err)
// Continue even if items sync fails - we have the pricelist metadata
} else {
slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount)
}
if pl.ID > latestServerID {
latestServerID = pl.ID
latestLocalID = localPL.ID
}
synced++ synced++
slog.Debug("synced pricelist", "version", pl.Version, "server_id", pl.ID) }
// Update component prices from latest pricelist
if latestLocalID > 0 {
updated, err := s.localDB.UpdateComponentPricesFromPricelist(latestLocalID)
if err != nil {
slog.Warn("failed to update component prices from pricelist", "error", err)
} else {
slog.Info("updated component prices from latest pricelist", "updated", updated)
}
} }
// Update last sync time // Update last sync time
@@ -154,8 +320,17 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
return int(existingCount), nil return int(existingCount), nil
} }
// Get database connection
mariaDB, err := s.getDB()
if err != nil {
return 0, fmt.Errorf("database not available: %w", err)
}
// Create repository
pricelistRepo := repository.NewPricelistRepository(mariaDB)
// Get items from server // Get items from server
serverItems, _, err := s.pricelistRepo.GetItems(localPL.ServerID, 0, 10000, "") serverItems, _, err := pricelistRepo.GetItems(localPL.ServerID, 0, 10000, "")
if err != nil { if err != nil {
return 0, fmt.Errorf("getting server pricelist items: %w", err) return 0, fmt.Errorf("getting server pricelist items: %w", err)
} }
@@ -243,6 +418,13 @@ func (s *Service) SyncPricelistsIfNeeded() error {
// PushPendingChanges pushes all pending changes to the server // PushPendingChanges pushes all pending changes to the server
func (s *Service) PushPendingChanges() (int, error) { func (s *Service) PushPendingChanges() (int, error) {
removed, err := s.localDB.PurgeOrphanConfigurationPendingChanges()
if err != nil {
slog.Warn("failed to purge orphan configuration pending changes", "error", err)
} else if removed > 0 {
slog.Info("purged orphan configuration pending changes", "removed", removed)
}
changes, err := s.localDB.GetPendingChanges() changes, err := s.localDB.GetPendingChanges()
if err != nil { if err != nil {
return 0, fmt.Errorf("getting pending changes: %w", err) return 0, fmt.Errorf("getting pending changes: %w", err)
@@ -256,8 +438,9 @@ func (s *Service) PushPendingChanges() (int, error) {
slog.Info("pushing pending changes", "count", len(changes)) slog.Info("pushing pending changes", "count", len(changes))
pushed := 0 pushed := 0
var syncedIDs []int64 var syncedIDs []int64
sortedChanges := prioritizeProjectChanges(changes)
for _, change := range changes { for _, change := range sortedChanges {
err := s.pushSingleChange(&change) err := s.pushSingleChange(&change)
if err != nil { if err != nil {
slog.Warn("failed to push change", "id", change.ID, "type", change.EntityType, "operation", change.Operation, "error", err) slog.Warn("failed to push change", "id", change.ID, "type", change.EntityType, "operation", change.Operation, "error", err)
@@ -284,6 +467,8 @@ func (s *Service) PushPendingChanges() (int, error) {
// pushSingleChange pushes a single pending change to the server // pushSingleChange pushes a single pending change to the server
func (s *Service) pushSingleChange(change *localdb.PendingChange) error { func (s *Service) pushSingleChange(change *localdb.PendingChange) error {
switch change.EntityType { switch change.EntityType {
case "project":
return s.pushProjectChange(change)
case "configuration": case "configuration":
return s.pushConfigurationChange(change) return s.pushConfigurationChange(change)
default: default:
@@ -291,6 +476,95 @@ func (s *Service) pushSingleChange(change *localdb.PendingChange) error {
} }
} }
func prioritizeProjectChanges(changes []localdb.PendingChange) []localdb.PendingChange {
if len(changes) < 2 {
return changes
}
projectChanges := make([]localdb.PendingChange, 0, len(changes))
otherChanges := make([]localdb.PendingChange, 0, len(changes))
for _, change := range changes {
if change.EntityType == "project" {
projectChanges = append(projectChanges, change)
continue
}
otherChanges = append(otherChanges, change)
}
sorted := make([]localdb.PendingChange, 0, len(changes))
sorted = append(sorted, projectChanges...)
sorted = append(sorted, otherChanges...)
return sorted
}
func (s *Service) pushProjectChange(change *localdb.PendingChange) error {
payload, err := decodeProjectChangePayload(change)
if err != nil {
return fmt.Errorf("decode project payload: %w", err)
}
mariaDB, err := s.getDB()
if err != nil {
return fmt.Errorf("database not available: %w", err)
}
projectRepo := repository.NewProjectRepository(mariaDB)
project := payload.Snapshot
project.UUID = payload.ProjectUUID
serverProject, err := projectRepo.GetByUUID(project.UUID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
if createErr := projectRepo.Create(&project); createErr != nil {
return fmt.Errorf("create project on server: %w", createErr)
}
} else {
return fmt.Errorf("get project on server: %w", err)
}
} else {
project.ID = serverProject.ID
if updateErr := projectRepo.Update(&project); updateErr != nil {
return fmt.Errorf("update project on server: %w", updateErr)
}
}
localProject, localErr := s.localDB.GetProjectByUUID(project.UUID)
if localErr == nil {
if project.ID > 0 {
serverID := project.ID
localProject.ServerID = &serverID
}
localProject.SyncStatus = "synced"
now := time.Now()
localProject.SyncedAt = &now
_ = s.localDB.SaveProject(localProject)
}
return nil
}
func decodeProjectChangePayload(change *localdb.PendingChange) (ProjectChangePayload, error) {
var payload ProjectChangePayload
if err := json.Unmarshal([]byte(change.Payload), &payload); err == nil && payload.ProjectUUID != "" {
if payload.Operation == "" {
payload.Operation = change.Operation
}
return payload, nil
}
var project models.Project
if err := json.Unmarshal([]byte(change.Payload), &project); err != nil {
return ProjectChangePayload{}, fmt.Errorf("unmarshal legacy project payload: %w", err)
}
return ProjectChangePayload{
ProjectUUID: project.UUID,
Operation: change.Operation,
IdempotencyKey: fmt.Sprintf("%s:%s:legacy", project.UUID, change.Operation),
Snapshot: project,
}, nil
}
// pushConfigurationChange pushes a configuration change to the server // pushConfigurationChange pushes a configuration change to the server
func (s *Service) pushConfigurationChange(change *localdb.PendingChange) error { func (s *Service) pushConfigurationChange(change *localdb.PendingChange) error {
switch change.Operation { switch change.Operation {
@@ -298,6 +572,12 @@ func (s *Service) pushConfigurationChange(change *localdb.PendingChange) error {
return s.pushConfigurationCreate(change) return s.pushConfigurationCreate(change)
case "update": case "update":
return s.pushConfigurationUpdate(change) return s.pushConfigurationUpdate(change)
case "rollback":
return s.pushConfigurationRollback(change)
case "deactivate":
return s.pushConfigurationDeactivate(change)
case "reactivate":
return s.pushConfigurationReactivate(change)
case "delete": case "delete":
return s.pushConfigurationDelete(change) return s.pushConfigurationDelete(change)
default: default:
@@ -307,15 +587,42 @@ func (s *Service) pushConfigurationChange(change *localdb.PendingChange) error {
// pushConfigurationCreate creates a configuration on the server // pushConfigurationCreate creates a configuration on the server
func (s *Service) pushConfigurationCreate(change *localdb.PendingChange) error { func (s *Service) pushConfigurationCreate(change *localdb.PendingChange) error {
var cfg models.Configuration payload, cfg, isStale, err := s.resolveConfigurationPayloadForPush(change)
if err := json.Unmarshal([]byte(change.Payload), &cfg); err != nil { if err != nil {
return fmt.Errorf("unmarshaling configuration: %w", err) return err
}
if isStale {
slog.Debug("skipping stale create event, newer version exists", "uuid", payload.ConfigurationUUID, "idempotency_key", payload.IdempotencyKey)
return nil
}
// Get database connection
mariaDB, err := s.getDB()
if err != nil {
return fmt.Errorf("database not available: %w", err)
}
// Create repository
configRepo := repository.NewConfigurationRepository(mariaDB)
if err := s.ensureConfigurationOwner(mariaDB, &cfg); err != nil {
return fmt.Errorf("resolve configuration owner: %w", err)
}
if err := s.ensureConfigurationProject(mariaDB, &cfg); err != nil {
return fmt.Errorf("resolve configuration project: %w", err)
} }
// Create on server // Create on server
if err := s.configRepo.Create(&cfg); err != nil { if err := configRepo.Create(&cfg); err != nil {
// Idempotency fallback: configuration may already be created remotely.
serverCfg, getErr := configRepo.GetByUUID(cfg.UUID)
if getErr != nil {
return fmt.Errorf("creating configuration on server: %w", err) return fmt.Errorf("creating configuration on server: %w", err)
} }
cfg.ID = serverCfg.ID
if updateErr := configRepo.Update(&cfg); updateErr != nil {
return fmt.Errorf("create fallback update on server: %w", updateErr)
}
}
// Update local configuration with server ID // Update local configuration with server ID
localCfg, err := s.localDB.GetConfigurationByUUID(cfg.UUID) localCfg, err := s.localDB.GetConfigurationByUUID(cfg.UUID)
@@ -326,15 +633,40 @@ func (s *Service) pushConfigurationCreate(change *localdb.PendingChange) error {
s.localDB.SaveConfiguration(localCfg) s.localDB.SaveConfiguration(localCfg)
} }
slog.Info("configuration created on server", "uuid", cfg.UUID, "server_id", cfg.ID) slog.Info("configuration created on server",
"uuid", cfg.UUID,
"server_id", cfg.ID,
"version_no", payload.CurrentVersionNo,
"version_id", payload.CurrentVersionID,
"idempotency_key", payload.IdempotencyKey,
)
return nil return nil
} }
// pushConfigurationUpdate updates a configuration on the server // pushConfigurationUpdate updates a configuration on the server
func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error { func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
var cfg models.Configuration payload, cfg, isStale, err := s.resolveConfigurationPayloadForPush(change)
if err := json.Unmarshal([]byte(change.Payload), &cfg); err != nil { if err != nil {
return fmt.Errorf("unmarshaling configuration: %w", err) return err
}
if isStale {
slog.Debug("skipping stale update event, newer version exists", "uuid", payload.ConfigurationUUID, "idempotency_key", payload.IdempotencyKey)
return nil
}
// Get database connection
mariaDB, err := s.getDB()
if err != nil {
return fmt.Errorf("database not available: %w", err)
}
// Create repository
configRepo := repository.NewConfigurationRepository(mariaDB)
if err := s.ensureConfigurationOwner(mariaDB, &cfg); err != nil {
return fmt.Errorf("resolve configuration owner: %w", err)
}
if err := s.ensureConfigurationProject(mariaDB, &cfg); err != nil {
return fmt.Errorf("resolve configuration project: %w", err)
} }
// Ensure we have a server ID before updating // Ensure we have a server ID before updating
@@ -347,7 +679,7 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
if localCfg.ServerID == nil { if localCfg.ServerID == nil {
// Configuration hasn't been synced yet, try to find it on server by UUID // Configuration hasn't been synced yet, try to find it on server by UUID
serverCfg, err := s.configRepo.GetByUUID(cfg.UUID) serverCfg, err := configRepo.GetByUUID(cfg.UUID)
if err != nil { if err != nil {
return fmt.Errorf("configuration not yet synced to server: %w", err) return fmt.Errorf("configuration not yet synced to server: %w", err)
} }
@@ -363,7 +695,7 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
} }
// Update on server // Update on server
if err := s.configRepo.Update(&cfg); err != nil { if err := configRepo.Update(&cfg); err != nil {
return fmt.Errorf("updating configuration on server: %w", err) return fmt.Errorf("updating configuration on server: %w", err)
} }
@@ -374,14 +706,251 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
s.localDB.SaveConfiguration(localCfg) s.localDB.SaveConfiguration(localCfg)
} }
slog.Info("configuration updated on server", "uuid", cfg.UUID) slog.Info("configuration updated on server",
"uuid", cfg.UUID,
"version_no", payload.CurrentVersionNo,
"version_id", payload.CurrentVersionID,
"idempotency_key", payload.IdempotencyKey,
"operation", payload.Operation,
"conflict_policy", payload.ConflictPolicy,
)
return nil return nil
} }
func (s *Service) ensureConfigurationOwner(mariaDB *gorm.DB, cfg *models.Configuration) error {
if cfg == nil {
return fmt.Errorf("configuration is nil")
}
ownerUsername := cfg.OwnerUsername
if ownerUsername == "" {
ownerUsername = s.localDB.GetDBUser()
cfg.OwnerUsername = ownerUsername
}
if ownerUsername == "" {
return fmt.Errorf("owner username is empty")
}
// user_id is legacy and no longer used for ownership in local-first mode.
// Keep it NULL on writes; ownership is represented by owner_username.
cfg.UserID = nil
cfg.AppVersion = appmeta.Version()
return nil
}
func (s *Service) ensureConfigurationProject(mariaDB *gorm.DB, cfg *models.Configuration) error {
if cfg == nil {
return fmt.Errorf("configuration is nil")
}
projectRepo := repository.NewProjectRepository(mariaDB)
if cfg.ProjectUUID != nil && *cfg.ProjectUUID != "" {
_, err := projectRepo.GetByUUID(*cfg.ProjectUUID)
if err == nil {
return nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
localProject, localErr := s.localDB.GetProjectByUUID(*cfg.ProjectUUID)
if localErr != nil {
return err
}
modelProject := localdb.LocalToProject(localProject)
if modelProject.OwnerUsername == "" {
modelProject.OwnerUsername = cfg.OwnerUsername
}
if createErr := projectRepo.Create(modelProject); createErr != nil {
return createErr
}
if modelProject.ID > 0 {
serverID := modelProject.ID
localProject.ServerID = &serverID
localProject.SyncStatus = "synced"
now := time.Now()
localProject.SyncedAt = &now
_ = s.localDB.SaveProject(localProject)
}
return nil
}
systemProject := &models.Project{}
err := mariaDB.
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(systemProject).Error
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
systemProject = &models.Project{
UUID: uuid.NewString(),
OwnerUsername: "",
Name: "Без проекта",
IsActive: true,
IsSystem: true,
}
if createErr := projectRepo.Create(systemProject); createErr != nil {
return createErr
}
}
cfg.ProjectUUID = &systemProject.UUID
return nil
}
func (s *Service) pushConfigurationRollback(change *localdb.PendingChange) error {
// Last-write-wins for now: rollback is pushed as an update with rollback metadata.
return s.pushConfigurationUpdate(change)
}
func (s *Service) pushConfigurationDeactivate(change *localdb.PendingChange) error {
// Local deactivate is represented as the latest snapshot push.
return s.pushConfigurationUpdate(change)
}
func (s *Service) pushConfigurationReactivate(change *localdb.PendingChange) error {
// Local reactivate is represented as the latest snapshot push.
return s.pushConfigurationUpdate(change)
}
func (s *Service) resolveConfigurationPayloadForPush(change *localdb.PendingChange) (ConfigurationChangePayload, models.Configuration, bool, error) {
payload, err := decodeConfigurationChangePayload(change)
if err != nil {
return ConfigurationChangePayload{}, models.Configuration{}, false, fmt.Errorf("decode configuration payload: %w", err)
}
eventVersionNo := payload.CurrentVersionNo
currentCfg, currentVersionID, currentVersionNo, err := s.loadCurrentConfigurationState(payload.ConfigurationUUID)
if err != nil {
// Local config may be gone (e.g. stale queue item after delete/cleanup). Treat as no-op.
if errors.Is(err, gorm.ErrRecordNotFound) {
return payload, payload.Snapshot, true, nil
}
// create->deactivate race: config may no longer be active/visible locally, skip stale create.
if change.Operation == "create" {
return payload, payload.Snapshot, true, nil
}
return ConfigurationChangePayload{}, models.Configuration{}, false, fmt.Errorf("load current local configuration state: %w", err)
}
if payload.ConflictPolicy == "" {
payload.ConflictPolicy = "last_write_wins"
}
if currentCfg.UUID != "" {
payload.Snapshot = currentCfg
if currentVersionID != "" {
payload.CurrentVersionID = currentVersionID
}
if currentVersionNo > 0 {
payload.CurrentVersionNo = currentVersionNo
}
}
isStale := false
if eventVersionNo > 0 && currentVersionNo > eventVersionNo {
// Keep only latest intent in queue; older versions become no-op.
isStale = true
}
if !isStale && change.Operation == "create" {
localCfg, getErr := s.localDB.GetConfigurationByUUID(payload.ConfigurationUUID)
if getErr == nil && !localCfg.IsActive {
isStale = true
}
}
return payload, payload.Snapshot, isStale, nil
}
func decodeConfigurationChangePayload(change *localdb.PendingChange) (ConfigurationChangePayload, error) {
var payload ConfigurationChangePayload
if err := json.Unmarshal([]byte(change.Payload), &payload); err == nil && payload.ConfigurationUUID != "" && payload.Snapshot.UUID != "" {
if payload.Operation == "" {
payload.Operation = change.Operation
}
return payload, nil
}
// Backward compatibility: legacy queue stored raw models.Configuration JSON.
var cfg models.Configuration
if err := json.Unmarshal([]byte(change.Payload), &cfg); err != nil {
return ConfigurationChangePayload{}, fmt.Errorf("unmarshal legacy configuration payload: %w", err)
}
return ConfigurationChangePayload{
EventID: "",
IdempotencyKey: fmt.Sprintf("%s:%s:legacy", cfg.UUID, change.Operation),
ConfigurationUUID: cfg.UUID,
ProjectUUID: cfg.ProjectUUID,
Operation: change.Operation,
ConflictPolicy: "last_write_wins",
Snapshot: cfg,
}, nil
}
func (s *Service) loadCurrentConfigurationState(configurationUUID string) (models.Configuration, string, int, error) {
localCfg, err := s.localDB.GetConfigurationByUUID(configurationUUID)
if err != nil {
return models.Configuration{}, "", 0, fmt.Errorf("get local configuration by uuid: %w", err)
}
cfg := *localdb.LocalToConfiguration(localCfg)
currentVersionID := ""
if localCfg.CurrentVersionID != nil {
currentVersionID = *localCfg.CurrentVersionID
}
currentVersionNo := 0
if currentVersionID != "" {
var version localdb.LocalConfigurationVersion
err = s.localDB.DB().
Where("id = ? AND configuration_uuid = ?", currentVersionID, configurationUUID).
First(&version).Error
if err == nil {
currentVersionNo = version.VersionNo
}
}
if currentVersionNo == 0 {
var latest localdb.LocalConfigurationVersion
err = s.localDB.DB().
Where("configuration_uuid = ?", configurationUUID).
Order("version_no DESC").
First(&latest).Error
if err == nil {
currentVersionNo = latest.VersionNo
currentVersionID = latest.ID
}
}
if currentVersionNo == 0 {
return models.Configuration{}, "", 0, fmt.Errorf("no local configuration version found for %s", configurationUUID)
}
return cfg, currentVersionID, currentVersionNo, nil
}
// NOTE: prepared for future conflict resolution:
// when server starts storing version metadata, we can compare payload.CurrentVersionNo
// against remote version and branch into custom strategies. For now use last-write-wins.
// pushConfigurationDelete deletes a configuration from the server // pushConfigurationDelete deletes a configuration from the server
func (s *Service) pushConfigurationDelete(change *localdb.PendingChange) error { func (s *Service) pushConfigurationDelete(change *localdb.PendingChange) error {
// Get database connection
mariaDB, err := s.getDB()
if err != nil {
return fmt.Errorf("database not available: %w", err)
}
// Create repository
configRepo := repository.NewConfigurationRepository(mariaDB)
// Get the configuration from server by UUID to get the ID // Get the configuration from server by UUID to get the ID
cfg, err := s.configRepo.GetByUUID(change.EntityUUID) cfg, err := configRepo.GetByUUID(change.EntityUUID)
if err != nil { if err != nil {
// Already deleted or not found, consider it successful // Already deleted or not found, consider it successful
slog.Warn("configuration not found on server, considering delete successful", "uuid", change.EntityUUID) slog.Warn("configuration not found on server, considering delete successful", "uuid", change.EntityUUID)
@@ -389,10 +958,30 @@ func (s *Service) pushConfigurationDelete(change *localdb.PendingChange) error {
} }
// Delete from server // Delete from server
if err := s.configRepo.Delete(cfg.ID); err != nil { if err := configRepo.Delete(cfg.ID); err != nil {
return fmt.Errorf("deleting configuration from server: %w", err) return fmt.Errorf("deleting configuration from server: %w", err)
} }
slog.Info("configuration deleted from server", "uuid", change.EntityUUID) slog.Info("configuration deleted on server", "uuid", change.EntityUUID)
return nil return nil
} }
func (s *Service) getDB() (*gorm.DB, error) {
if s.directDB != nil {
return s.directDB, nil
}
if s.connMgr == nil {
return nil, ErrOffline
}
return s.connMgr.GetDB()
}
func (s *Service) getConnectionStatus() db.ConnectionStatus {
if s.directDB != nil {
return db.ConnectionStatus{IsConnected: true}
}
if s.connMgr == nil {
return db.ConnectionStatus{IsConnected: false}
}
return s.connMgr.GetStatus()
}

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,273 @@
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 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 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,
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,
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

@@ -5,23 +5,23 @@ import (
"log/slog" "log/slog"
"time" "time"
"gorm.io/gorm" "git.mchus.pro/mchus/quoteforge/internal/db"
) )
// Worker performs background synchronization at regular intervals // Worker performs background synchronization at regular intervals
type Worker struct { type Worker struct {
service *Service service *Service
db *gorm.DB connMgr *db.ConnectionManager
interval time.Duration interval time.Duration
logger *slog.Logger logger *slog.Logger
stopCh chan struct{} stopCh chan struct{}
} }
// NewWorker creates a new background sync worker // NewWorker creates a new background sync worker
func NewWorker(service *Service, db *gorm.DB, interval time.Duration) *Worker { func NewWorker(service *Service, connMgr *db.ConnectionManager, interval time.Duration) *Worker {
return &Worker{ return &Worker{
service: service, service: service,
db: db, connMgr: connMgr,
interval: interval, interval: interval,
logger: slog.Default(), logger: slog.Default(),
stopCh: make(chan struct{}), stopCh: make(chan struct{}),
@@ -30,11 +30,7 @@ func NewWorker(service *Service, db *gorm.DB, interval time.Duration) *Worker {
// isOnline checks if the database connection is available // isOnline checks if the database connection is available
func (w *Worker) isOnline() bool { func (w *Worker) isOnline() bool {
sqlDB, err := w.db.DB() return w.connMgr.IsOnline()
if err != nil {
return false
}
return sqlDB.Ping() == nil
} }
// Start begins the background sync loop in a goroutine // Start begins the background sync loop in a goroutine

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 = '';

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

@@ -203,12 +203,6 @@
</div> </div>
<script> <script>
// Fallback showToast function for cases where base.html isn't loaded properly
const showToast = window.showToast || function(msg, type) {
// Simple fallback: just alert the message
alert(`${type ? type + ': ' : ''}${msg}`);
};
let currentTab = 'alerts'; let currentTab = 'alerts';
let currentPage = 1; let currentPage = 1;
let totalPages = 1; let totalPages = 1;
@@ -597,8 +591,21 @@ async function fetchPreview() {
document.getElementById('modal-new-price').textContent = document.getElementById('modal-new-price').textContent =
data.new_price ? '$' + parseFloat(data.new_price).toFixed(2) : '—'; data.new_price ? '$' + parseFloat(data.new_price).toFixed(2) : '—';
// Update quote count // Update quote count with new format "N (всего: M)"
document.getElementById('modal-quote-count').textContent = data.quote_count || 0; let quoteCountText = '';
if (data.quote_count_period !== undefined && data.quote_count_total !== undefined) {
if (data.quote_count_period === data.quote_count_total) {
// If period count equals total count, just show the total
quoteCountText = data.quote_count_total;
} else {
// Show both counts in format "N (всего: M)"
quoteCountText = data.quote_count_period + ' (всего: ' + data.quote_count_total + ')';
}
} else {
// Fallback for older API responses
quoteCountText = data.quote_count || 0;
}
document.getElementById('modal-quote-count').textContent = quoteCountText;
} }
} catch(e) { } catch(e) {
console.error('Preview fetch error:', e); console.error('Preview fetch error:', e);
@@ -854,7 +861,7 @@ function renderAllConfigs(configs) {
const date = new Date(c.created_at).toLocaleDateString('ru-RU'); const date = new Date(c.created_at).toLocaleDateString('ru-RU');
const total = c.total_price ? '$' + c.total_price.toLocaleString('en-US', {minimumFractionDigits: 2}) : '—'; const total = c.total_price ? '$' + c.total_price.toLocaleString('en-US', {minimumFractionDigits: 2}) : '—';
const serverCount = c.server_count ? c.server_count : 1; 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 += '<tr class="hover:bg-gray-50">';
html += '<td class="px-3 py-2 text-sm text-gray-500">' + date + '</td>'; html += '<td class="px-3 py-2 text-sm text-gray-500">' + date + '</td>';
@@ -891,9 +898,6 @@ document.addEventListener('DOMContentLoaded', () => {
const initialTab = urlParams.get('tab') || 'alerts'; const initialTab = urlParams.get('tab') || 'alerts';
loadTab(initialTab); loadTab(initialTab);
// Check write permission for admin pricing link
checkWritePermission();
// Add event listeners for preview updates // Add event listeners for preview updates
document.getElementById('modal-period').addEventListener('change', fetchPreview); document.getElementById('modal-period').addEventListener('change', fetchPreview);
document.getElementById('modal-coefficient').addEventListener('input', debounceFetchPreview); document.getElementById('modal-coefficient').addEventListener('input', debounceFetchPreview);
@@ -1028,7 +1032,23 @@ function closePricelistsCreateModal() {
document.getElementById('pricelists-create-modal').classList.remove('flex'); document.getElementById('pricelists-create-modal').classList.remove('flex');
} }
async function checkOnlineStatus() {
try {
const resp = await fetch('/api/db-status');
const data = await resp.json();
return data.connected === true;
} catch(e) {
return false;
}
}
async function createPricelist() { async function createPricelist() {
// Check if online before creating
const isOnline = await checkOnlineStatus();
if (!isOnline) {
throw new Error('Создание прайслистов доступно только в онлайн режиме');
}
const resp = await fetch('/api/pricelists', { const resp = await fetch('/api/pricelists', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -1046,6 +1066,13 @@ async function createPricelist() {
} }
async function deletePricelist(id) { async function deletePricelist(id) {
// Check if online before deleting
const isOnline = await checkOnlineStatus();
if (!isOnline) {
showToast('Удаление прайслистов доступно только в онлайн режиме', 'error');
return;
}
if (!confirm('Удалить этот прайслист?')) return; if (!confirm('Удалить этот прайслист?')) return;
try { try {

View File

@@ -19,8 +19,8 @@
<div class="flex items-center space-x-8"> <div class="flex items-center space-x-8">
<a href="/" class="text-xl font-bold text-blue-600">QuoteForge</a> <a href="/" class="text-xl font-bold text-blue-600">QuoteForge</a>
<div class="hidden md:flex space-x-4"> <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 hidden">Администратор цен</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> <a href="/setup" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Настройки</a>
</div> </div>
</div> </div>
@@ -44,6 +44,52 @@
<div id="toast" class="fixed bottom-4 right-4 z-50"></div> <div id="toast" class="fixed bottom-4 right-4 z-50"></div>
<!-- Sync Info Modal -->
<div id="sync-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<div class="bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
<div class="p-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium text-gray-900">Информация о синхронизации</h3>
<button onclick="closeSyncModal()" class="text-gray-400 hover:text-gray-500">
<svg class="w-6 h-6" 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="space-y-4">
<div>
<h4 class="font-medium text-gray-900">Статус БД</h4>
<p id="modal-db-status" class="text-sm text-gray-600">Проверка...</p>
</div>
<div>
<h4 class="font-medium text-gray-900">Количество ошибок</h4>
<p id="modal-error-count" class="text-sm text-gray-600">0</p>
</div>
<div>
<h4 class="font-medium text-gray-900">Последняя синхронизация</h4>
<p id="modal-last-sync" class="text-sm text-gray-600">-</p>
</div>
<div>
<h4 class="font-medium text-gray-900">Список ошибок</h4>
<div id="modal-errors-list" class="mt-2 text-sm text-gray-600 max-h-40 overflow-y-auto">
<p>Нет ошибок</p>
</div>
</div>
</div>
<div class="mt-6 flex justify-end">
<button onclick="closeSyncModal()" class="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700">
Закрыть
</button>
</div>
</div>
</div>
</div>
<footer class="fixed bottom-0 left-0 right-0 bg-gray-800 text-gray-300 text-xs py-1 px-4"> <footer class="fixed bottom-0 left-0 right-0 bg-gray-800 text-gray-300 text-xs py-1 px-4">
<div class="max-w-7xl mx-auto flex justify-between"> <div class="max-w-7xl mx-auto flex justify-between">
<span id="db-status">БД: проверка...</span> <span id="db-status">БД: проверка...</span>
@@ -59,69 +105,79 @@
setTimeout(() => el.innerHTML = '', 3000); setTimeout(() => el.innerHTML = '', 3000);
} }
// Open sync modal
function openSyncModal() {
const modal = document.getElementById('sync-modal');
if (modal) {
modal.classList.remove('hidden');
// Load sync info when modal opens
loadSyncInfo();
}
}
// Close sync modal
function closeSyncModal() {
const modal = document.getElementById('sync-modal');
if (modal) {
modal.classList.add('hidden');
}
}
// Load sync info for modal
async function loadSyncInfo() {
try {
const resp = await fetch('/api/sync/info');
const data = await resp.json();
document.getElementById('modal-db-status').textContent = data.is_online ? 'Подключено' : 'Отключено';
document.getElementById('modal-error-count').textContent = data.error_count;
if (data.last_sync_at) {
const date = new Date(data.last_sync_at);
document.getElementById('modal-last-sync').textContent = date.toLocaleString('ru-RU');
} else {
document.getElementById('modal-last-sync').textContent = 'Нет данных';
}
// Load error list
const errorsList = document.getElementById('modal-errors-list');
if (data.errors && data.errors.length > 0) {
errorsList.innerHTML = data.errors.map(error =>
`<div class="mb-1"><span class="font-medium">${error.timestamp}</span>: ${error.message}</div>`
).join('');
} else {
errorsList.innerHTML = '<p>Нет ошибок</p>';
}
} catch(e) {
console.error('Failed to load sync info:', e);
document.getElementById('modal-db-status').textContent = 'Ошибка загрузки';
document.getElementById('modal-error-count').textContent = '0';
document.getElementById('modal-last-sync').textContent = '-';
document.getElementById('modal-errors-list').innerHTML = '<p>Ошибка загрузки данных</p>';
}
}
// Event delegation for sync dropdown and actions // Event delegation for sync dropdown and actions
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
checkDbStatus(); checkDbStatus();
checkWritePermission(); checkWritePermission();
}); });
// Handle keyboard navigation for dropdown // Event delegation for sync actions
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
const dropdownMenu = document.getElementById('sync-dropdown-menu');
if (dropdownMenu) {
dropdownMenu.classList.add('hidden');
}
}
});
// Event delegation for all sync actions
document.body.addEventListener('click', function(e) { document.body.addEventListener('click', function(e) {
// Handle dropdown toggle // Handle sync button click (full sync only)
const dropdownButton = e.target.closest('#sync-dropdown-button'); const syncButton = e.target.closest('#sync-button');
if (dropdownButton) { if (syncButton) {
e.stopPropagation(); e.stopPropagation();
const dropdownMenu = document.getElementById('sync-dropdown-menu'); const button = syncButton;
if (dropdownMenu) {
dropdownMenu.classList.toggle('hidden');
// Update aria-expanded
const isExpanded = dropdownMenu.classList.contains('hidden');
dropdownButton.setAttribute('aria-expanded', !isExpanded);
}
}
// Handle sync actions
const actionButton = e.target.closest('[data-action]');
if (actionButton) {
const action = actionButton.dataset.action;
const button = actionButton; // Keep reference to original button
// Add loading state // Add loading state
const originalHTML = button.innerHTML; const originalHTML = button.innerHTML;
button.disabled = true; button.disabled = true;
button.innerHTML = '<svg class="animate-spin w-4 h-4 inline mr-2" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg> Синхронизация...'; button.innerHTML = '<svg class="animate-spin w-4 h-4 inline mr-2" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>';
if (action === 'push-changes') {
pushPendingChanges(button, originalHTML);
} else if (action === 'full-sync') {
fullSync(button, originalHTML); fullSync(button, originalHTML);
} }
}
});
// Close dropdown when clicking outside
document.body.addEventListener('click', function(e) {
const dropdownButton = document.getElementById('sync-dropdown-button');
const dropdownMenu = document.getElementById('sync-dropdown-menu');
if (dropdownButton && dropdownMenu &&
!dropdownButton.contains(e.target) &&
!dropdownMenu.contains(e.target)) {
dropdownMenu.classList.add('hidden');
if (dropdownButton) {
dropdownButton.setAttribute('aria-expanded', 'false');
}
}
}); });
// Refactored sync action function to reduce duplication // Refactored sync action function to reduce duplication
@@ -132,8 +188,8 @@
if (data.success) { if (data.success) {
showToast(successMessage, 'success'); showToast(successMessage, 'success');
// Update last sync time // Update last sync time - removed since dropdown is gone
loadLastSyncTime(); // loadLastSyncTime();
} else { } else {
showToast('Ошибка: ' + (data.error || 'неизвестная ошибка'), 'error'); showToast('Ошибка: ' + (data.error || 'неизвестная ошибка'), 'error');
} }
@@ -181,37 +237,36 @@
} }
} }
// Admin pricing link is now always visible
// Write permission is checked at operation time (create/delete)
async function checkWritePermission() { async function checkWritePermission() {
try { // No longer needed - link always visible in offline-first mode
const resp = await fetch('/api/pricelists/can-write'); // Operations will check online status when executed
const data = await resp.json();
if (data.can_write) {
const link = document.getElementById('admin-pricing-link');
if (link) link.classList.remove('hidden');
}
} catch(e) {
console.error('Failed to check write permission:', e);
}
} }
// Load last sync time for dropdown // Load last sync time for dropdown (removed since dropdown is gone)
async function loadLastSyncTime() { // async function loadLastSyncTime() {
try { // try {
const resp = await fetch('/api/sync/status'); // const resp = await fetch('/api/sync/status');
const data = await resp.json(); // const data = await resp.json();
if (data.last_pricelist_sync) { // if (data.last_pricelist_sync) {
const date = new Date(data.last_pricelist_sync); // const date = new Date(data.last_pricelist_sync);
document.getElementById('last-sync-time').textContent = date.toLocaleString('ru-RU'); // document.getElementById('last-sync-time').textContent = date.toLocaleString('ru-RU');
} else { // } else {
document.getElementById('last-sync-time').textContent = 'Нет данных'; // document.getElementById('last-sync-time').textContent = 'Нет данных';
} // }
} catch(e) { // } catch(e) {
console.error('Failed to load last sync time:', e); // console.error('Failed to load last sync time:', e);
} // }
} // }
// Load last sync time when page loads // Call functions immediately to ensure they run even before DOMContentLoaded
document.addEventListener('DOMContentLoaded', loadLastSyncTime); // This ensures username and admin link are visible ASAP
checkDbStatus();
checkWritePermission();
// Load last sync time - removed since dropdown is gone
// document.addEventListener('DOMContentLoaded', loadLastSyncTime);
</script> </script>
</body> </body>
</html> </html>

View File

@@ -4,10 +4,27 @@
<div class="space-y-4"> <div class="space-y-4">
<h1 class="text-2xl font-bold">Мои конфигурации</h1> <h1 class="text-2xl font-bold">Мои конфигурации</h1>
<div class="mt-4"> <div id="action-buttons" class="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3">
<button onclick="openCreateModal()" class="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"> <button onclick="openCreateModal()" class="py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
+ Создать новую конфигурацию + Создать новую конфигурацию
</button> </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>
<div id="pricelist-badge" class="mt-4 text-sm text-gray-600 hidden"> <div id="pricelist-badge" class="mt-4 text-sm text-gray-600 hidden">
@@ -44,6 +61,13 @@
<input type="text" id="opportunity-number" placeholder="Например: OPP-2024-001" <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"> class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div> </div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Проект</label>
<select id="create-project-select"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">Без проекта</option>
</select>
</div>
</div> </div>
<div class="flex justify-end space-x-3 mt-6"> <div class="flex justify-end space-x-3 mt-6">
@@ -107,23 +131,82 @@
</div> </div>
</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>" не найден. Создать и привязать квоту?</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 onclick="confirmCreateProjectOnMove()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Создать и привязать</button>
</div>
</div>
</div>
<script> <script>
// Pagination state // Pagination state
let currentPage = 1; let currentPage = 1;
let totalPages = 1; let totalPages = 1;
let perPage = 20; let perPage = 20;
let configStatusMode = 'active';
let configsSearch = '';
let projectsCache = [];
let projectNameByUUID = {};
let pendingMoveConfigUUID = '';
let pendingMoveProjectName = '';
function renderConfigs(configs) { function renderConfigs(configs) {
const emptyText = configStatusMode === 'archived'
? 'Архив пуст'
: 'Нет сохраненных конфигураций';
if (configs.length === 0) { if (configs.length === 0) {
document.getElementById('configs-list').innerHTML = 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; return;
} }
let html = '<div class="bg-white rounded-lg shadow overflow-hidden"><table class="w-full">'; let html = '<div class="bg-white rounded-lg shadow overflow-hidden"><table class="w-full">';
html += '<thead class="bg-gray-50"><tr>'; 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">Название</th>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Цена (за 1 шт)</th>'; html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Цена (за 1 шт)</th>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Кол-во</th>'; html += '<th class="px-4 py-3 text-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>';
@@ -134,6 +217,10 @@ function renderConfigs(configs) {
const date = new Date(c.created_at).toLocaleDateString('ru-RU'); const date = new Date(c.created_at).toLocaleDateString('ru-RU');
const total = c.total_price ? '$' + c.total_price.toLocaleString('en-US', {minimumFractionDigits: 2}) : '—'; const total = c.total_price ? '$' + c.total_price.toLocaleString('en-US', {minimumFractionDigits: 2}) : '—';
const serverCount = c.server_count ? c.server_count : 1; 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) // Calculate price per unit (total / server count)
let pricePerUnit = '—'; let pricePerUnit = '—';
@@ -144,11 +231,41 @@ function renderConfigs(configs) {
html += '<tr class="hover:bg-gray-50">'; 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 text-gray-500">' + date + '</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 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">' + 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-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">' + total + '</td>';
html += '<td class="px-4 py-3 text-sm text-right space-x-2">'; 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">';
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 += '<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 += '<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 += '<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>';
@@ -159,11 +276,12 @@ function renderConfigs(configs) {
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 += '<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 += '</svg>';
html += '</button>'; html += '</button>';
html += '<button onclick="deleteConfig(\'' + c.uuid + '\')" class="text-red-600 hover:text-red-800" title="Удалить">'; 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 += '<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 += '<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 += '</svg>';
html += '</button>'; html += '</button>';
}
html += '</td></tr>'; html += '</td></tr>';
}); });
@@ -178,13 +296,25 @@ function escapeHtml(text) {
} }
async function deleteConfig(uuid) { async function deleteConfig(uuid) {
if (!confirm('Удалить?')) return; if (!confirm('Переместить конфигурацию в архив?')) return;
await fetch('/api/configs/' + uuid, { await fetch('/api/configs/' + uuid, {
method: 'DELETE' method: 'DELETE'
}); });
loadConfigs(); 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) { function openRenameModal(uuid, currentName) {
document.getElementById('rename-uuid').value = uuid; document.getElementById('rename-uuid').value = uuid;
document.getElementById('rename-input').value = currentName; document.getElementById('rename-input').value = currentName;
@@ -295,6 +425,8 @@ async function createConfig() {
return; return;
} }
const projectUUID = document.getElementById('create-project-select').value;
try { try {
const resp = await fetch('/api/configs', { const resp = await fetch('/api/configs', {
method: 'POST', method: 'POST',
@@ -305,7 +437,8 @@ async function createConfig() {
name: name, name: name,
items: [], items: [],
notes: '', notes: '',
server_count: 1 server_count: 1,
project_uuid: projectUUID || null
}) })
}); });
@@ -322,6 +455,129 @@ async function createConfig() {
} }
} }
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 openCreateProjectOnMoveModal(projectName) {
document.getElementById('create-project-on-move-name').textContent = projectName;
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 = '';
}
async function confirmCreateProjectOnMove() {
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 newProject = await createResp.json();
const moved = await moveConfigToProject(configUUID, newProject.uuid);
if (moved) {
closeCreateProjectOnMoveModal();
closeMoveProjectModal();
}
} 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;
}
}
// Close modal on outside click // Close modal on outside click
document.getElementById('create-modal').addEventListener('click', function(e) { document.getElementById('create-modal').addEventListener('click', function(e) {
if (e.target === this) { if (e.target === this) {
@@ -341,12 +597,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 // Close modal on Escape key
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') { if (e.key === 'Escape') {
closeCreateModal(); closeCreateModal();
closeRenameModal(); closeRenameModal();
closeCloneModal(); closeCloneModal();
closeMoveProjectModal();
closeCreateProjectOnMoveModal();
} }
}); });
@@ -379,18 +649,46 @@ function nextPage() {
} }
function updatePagination(total) { function updatePagination(total) {
totalPages = Math.ceil(total / perPage); totalPages = Math.max(1, Math.ceil(total / perPage));
document.getElementById('page-info').textContent = document.getElementById('page-info').textContent =
'Страница ' + currentPage + ' из ' + totalPages + ' (всего: ' + total + ')'; 'Страница ' + currentPage + ' из ' + totalPages + ' (всего: ' + total + ')';
document.getElementById('btn-prev').disabled = currentPage <= 1; document.getElementById('btn-prev').disabled = currentPage <= 1;
document.getElementById('btn-next').disabled = currentPage >= totalPages; document.getElementById('btn-next').disabled = currentPage >= totalPages;
if (total <= perPage) {
document.getElementById('pagination').classList.add('hidden');
} else {
document.getElementById('pagination').classList.remove('hidden'); 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 // Load configs with pagination
async function loadConfigs() { async function loadConfigs() {
try { 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) { if (!resp.ok) {
document.getElementById('configs-list').innerHTML = document.getElementById('configs-list').innerHTML =
@@ -407,13 +705,78 @@ 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() { document.addEventListener('DOMContentLoaded', function() {
loadConfigs(); applyStatusModeUI();
loadProjectsForConfigUI().then(loadConfigs);
// Load latest pricelist version for badge // Load latest pricelist version for badge
loadLatestPricelistVersion(); 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 || []);
const select = document.getElementById('create-project-select');
if (select) {
select.innerHTML = '<option value="">Без проекта</option>';
projectsCache.forEach(project => {
projectNameByUUID[project.uuid] = project.name;
if (!project.is_active) return;
const option = document.createElement('option');
option.value = project.uuid;
option.textContent = project.name;
select.appendChild(option);
});
}
} catch (e) {
// keep default behavior without project selection data
}
}
async function loadLatestPricelistVersion() { async function loadLatestPricelistVersion() {
try { try {
const resp = await fetch('/api/pricelists/latest'); const resp = await fetch('/api/pricelists/latest');
@@ -421,18 +784,23 @@ async function loadLatestPricelistVersion() {
const pricelist = await resp.json(); const pricelist = await resp.json();
document.getElementById('pricelist-version').textContent = pricelist.version; document.getElementById('pricelist-version').textContent = pricelist.version;
document.getElementById('pricelist-badge').classList.remove('hidden'); document.getElementById('pricelist-badge').classList.remove('hidden');
} else if (resp.status === 404) {
// No active pricelist (normal in offline mode or when not synced)
document.getElementById('pricelist-version').textContent = 'Не загружен';
document.getElementById('pricelist-badge').classList.remove('hidden');
document.getElementById('pricelist-badge').classList.add('bg-gray-100', 'text-gray-600');
} else { } else {
// Show error in badge // Real error
document.getElementById('pricelist-version').textContent = 'Ошибка загрузки'; document.getElementById('pricelist-version').textContent = 'Ошибка загрузки';
document.getElementById('pricelist-badge').classList.remove('hidden'); document.getElementById('pricelist-badge').classList.remove('hidden');
document.getElementById('pricelist-badge').classList.add('bg-red-100', 'text-red-800'); document.getElementById('pricelist-badge').classList.add('bg-red-100', 'text-red-800');
} }
} catch(e) { } catch(e) {
// Show error in badge // Network error or other exception
console.error('Failed to load pricelist version:', e); console.error('Failed to load pricelist version:', e);
document.getElementById('pricelist-version').textContent = 'Ошибка загрузки'; document.getElementById('pricelist-version').textContent = 'Не доступен';
document.getElementById('pricelist-badge').classList.remove('hidden'); document.getElementById('pricelist-badge').classList.remove('hidden');
document.getElementById('pricelist-badge').classList.add('bg-red-100', 'text-red-800'); document.getElementById('pricelist-badge').classList.add('bg-gray-100', 'text-gray-600');
} }
} }
</script> </script>

View File

@@ -1,13 +1,13 @@
{{define "sync_status"}} {{define "sync_status"}}
<div class="flex items-center gap-2 relative"> <div class="flex items-center gap-2 relative">
{{if .IsOffline}} {{if .IsOffline}}
<span class="flex items-center gap-1 text-red-600" title="Offline"> <span class="flex items-center gap-1 text-red-600 cursor-pointer" title="Offline" onclick="openSyncModal()">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg> </svg>
</span> </span>
{{else}} {{else}}
<span class="flex items-center gap-1 text-green-600" title="Online"> <span class="flex items-center gap-1 text-green-600 cursor-pointer" title="Online" onclick="openSyncModal()">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg> </svg>
@@ -15,7 +15,7 @@
{{end}} {{end}}
{{if gt .PendingCount 0}} {{if gt .PendingCount 0}}
<span class="bg-yellow-100 text-yellow-800 px-2 py-0.5 rounded-full text-xs font-medium flex items-center gap-1"> <span class="bg-yellow-100 text-yellow-800 px-2 py-0.5 rounded-full text-xs font-medium flex items-center gap-1 cursor-pointer" onclick="openSyncModal()">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg> </svg>
@@ -23,40 +23,15 @@
</span> </span>
{{end}} {{end}}
<!-- Dropdown button for sync actions --> <!-- Sync button (full sync only) -->
<div class="relative"> <div class="relative">
<button id="sync-dropdown-button" <button id="sync-button"
aria-label="Меню синхронизации" aria-label="Синхронизация"
aria-haspopup="true"
aria-expanded="false"
class="text-gray-600 hover:text-gray-800 text-xs flex items-center gap-1"> class="text-gray-600 hover:text-gray-800 text-xs flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg> </svg>
</button> </button>
<!-- Dropdown menu -->
<div id="sync-dropdown-menu" class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 hidden z-50">
<button data-action="push-changes" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 w-full text-left">
<svg class="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
Push changes
</button>
<button data-action="full-sync" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 w-full text-left">
<svg class="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
Full sync
</button>
<div class="border-t border-gray-100 my-1"></div>
<div class="px-4 py-2 text-xs text-gray-500">
<svg class="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
Последняя синхронизация: <span id="last-sync-time">-</span>
</div>
</div>
</div> </div>
</div> </div>
{{end}} {{end}}

View File

@@ -0,0 +1,476 @@
{{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-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 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;
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" .}}

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

@@ -0,0 +1,231 @@
{{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="createProject()" 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>
<script>
let status = 'active';
let projectsSearch = '';
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 setStatus(value) {
status = value;
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 = [];
try {
const resp = await fetch('/api/projects?status=' + status + '&search=' + encodeURIComponent(projectsSearch));
if (!resp.ok) {
throw new Error('HTTP ' + resp.status);
}
const data = await resp.json();
rows = data.projects || [];
} catch (e) {
root.innerHTML = '<div class="text-red-600">Ошибка загрузки проектов: ' + escapeHtml(String(e.message || e)) + '</div>';
return;
}
if (!rows.length) {
root.innerHTML = '<div class="text-gray-500">Проектов нет</div>';
return;
}
let html = '<div class="overflow-x-auto"><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-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></thead><tbody class="divide-y">';
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-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>';
root.innerHTML = html;
}
async function createProject() {
const name = prompt('Название проекта');
if (!name || !name.trim()) return;
const resp = await fetch('/api/projects', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: name.trim()})
});
if (!resp.ok) {
alert('Не удалось создать проект');
return;
}
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();
loadProjects();
});
</script>
{{end}}
{{template "base" .}}

View File

@@ -87,12 +87,14 @@
<script> <script>
function showStatus(message, type) { function showStatus(message, type) {
const status = document.getElementById('status'); const status = document.getElementById('status');
status.classList.remove('hidden', 'bg-green-100', 'text-green-800', 'bg-red-100', 'text-red-800', 'bg-blue-100', 'text-blue-800'); status.classList.remove('hidden', 'bg-green-100', 'text-green-800', 'bg-red-100', 'text-red-800', 'bg-blue-100', 'text-blue-800', 'bg-yellow-100', 'text-yellow-800');
if (type === 'success') { if (type === 'success') {
status.classList.add('bg-green-100', 'text-green-800'); status.classList.add('bg-green-100', 'text-green-800');
} else if (type === 'error') { } else if (type === 'error') {
status.classList.add('bg-red-100', 'text-red-800'); status.classList.add('bg-red-100', 'text-red-800');
} else if (type === 'warning') {
status.classList.add('bg-yellow-100', 'text-yellow-800');
} else { } else {
status.classList.add('bg-blue-100', 'text-blue-800'); status.classList.add('bg-blue-100', 'text-blue-800');
} }
@@ -156,6 +158,16 @@
}, 1000); // Check every second }, 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) { document.getElementById('setup-form').addEventListener('submit', async function(e) {
e.preventDefault(); e.preventDefault();
showStatus('Сохранение настроек...', 'info'); showStatus('Сохранение настроек...', 'info');
@@ -171,12 +183,20 @@
if (data.success) { if (data.success) {
showStatus('✓ ' + data.message, 'success'); showStatus('✓ ' + data.message, 'success');
// Wait for restart and redirect to home
// Check if restart is required
if (data.restart_required) {
setTimeout(() => {
requestRestartAndWait();
}, 2000);
} else {
// In setup mode, auto-restart is happening
setTimeout(() => { setTimeout(() => {
showStatus('✓ Настройки сохранены. Проверка подключения...', 'success'); showStatus('✓ Настройки сохранены. Проверка подключения...', 'success');
// Poll until server is back // Poll until server is back
checkServerReady(); checkServerReady();
}, 2000); }, 2000);
}
} else { } else {
showStatus(data.error, 'error'); showStatus(data.error, 'error');
} }