Compare commits
28 Commits
v0.2.7
...
06d0e8b14b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06d0e8b14b | ||
|
|
b1b50ce2ef | ||
|
|
6ab1e9899e | ||
|
|
a1d21927a3 | ||
|
|
a90c07c879 | ||
|
|
e9307c4bad | ||
|
|
1b48401828 | ||
|
|
4a86f7b7ba | ||
|
|
955467fbea | ||
|
|
9ddffe48e9 | ||
|
|
4732605925 | ||
|
|
d318a7f462 | ||
|
|
1bec110d91 | ||
|
|
6392e4b4a9 | ||
|
|
8f7defdb8a | ||
|
|
0c190efda4 | ||
|
|
41c0a47f54 | ||
|
|
f4f92dea66 | ||
|
|
f42b850734 | ||
|
|
d094d39427 | ||
|
|
4509e93864 | ||
|
|
e2800b06f9 | ||
|
|
7c606af2bb | ||
|
|
fabd30650d | ||
|
|
40ade651b0 | ||
|
|
1b87c53609 | ||
| a3dc264efd | |||
| 20056f3593 |
22
.gitignore
vendored
22
.gitignore
vendored
@@ -13,6 +13,28 @@ config.yaml
|
|||||||
/cron
|
/cron
|
||||||
/bin/
|
/bin/
|
||||||
|
|
||||||
|
# Local Go build cache used in sandboxed runs
|
||||||
|
.gocache/
|
||||||
|
|
||||||
|
# Local tooling state
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# Editor settings
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Temp and logs
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Go test/build artifacts
|
||||||
|
*.out
|
||||||
|
*.test
|
||||||
|
coverage/
|
||||||
|
|
||||||
# ---> macOS
|
# ---> macOS
|
||||||
# General
|
# General
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
140
README.md
140
README.md
@@ -85,6 +85,82 @@ auth:
|
|||||||
go run ./cmd/qfs -migrate
|
go run ./cmd/qfs -migrate
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Мигратор OPS -> проекты (preview/apply)
|
||||||
|
|
||||||
|
Переносит квоты, чьи названия начинаются с `OPS-xxxx` (где `x` — цифра), в проект `OPS-xxxx`.
|
||||||
|
Если проекта нет, он будет создан; если архивный — реактивирован.
|
||||||
|
|
||||||
|
Сначала всегда смотрите preview:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/migrate_ops_projects -config config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Применение изменений:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/migrate_ops_projects -config config.yaml -apply
|
||||||
|
```
|
||||||
|
|
||||||
|
Без интерактивного подтверждения:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/migrate_ops_projects -config config.yaml -apply -yes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Минимальные права БД для пользователя квотаций
|
||||||
|
|
||||||
|
Если нужен пользователь, который может работать с конфигурациями, но не может создавать/удалять прайслисты:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 1) Создать пользователя (если его ещё нет)
|
||||||
|
CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY 'StrongPassword!';
|
||||||
|
|
||||||
|
-- 2) Если пользователь уже существовал, принудительно обновить пароль
|
||||||
|
ALTER USER 'quote_user'@'%' IDENTIFIED BY 'StrongPassword!';
|
||||||
|
|
||||||
|
-- 3) (Опционально, но рекомендуется) удалить дубли пользователя с другими host,
|
||||||
|
-- чтобы не возникало конфликтов вида user@localhost vs user@'%'
|
||||||
|
DROP USER IF EXISTS 'quote_user'@'localhost';
|
||||||
|
DROP USER IF EXISTS 'quote_user'@'127.0.0.1';
|
||||||
|
DROP USER IF EXISTS 'quote_user'@'::1';
|
||||||
|
|
||||||
|
-- 4) Сбросить лишние права
|
||||||
|
REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'quote_user'@'%';
|
||||||
|
|
||||||
|
-- 5) Чтение данных для конфигуратора и синка
|
||||||
|
GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%';
|
||||||
|
|
||||||
|
-- 6) Работа с конфигурациями
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
|
||||||
|
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
|
||||||
|
SHOW GRANTS FOR 'quote_user'@'%';
|
||||||
|
SHOW CREATE USER 'quote_user'@'%';
|
||||||
|
```
|
||||||
|
|
||||||
|
Полный набор прав для пользователя квотаций:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
GRANT USAGE ON *.* TO 'quote_user'@'%' IDENTIFIED BY 'StrongPassword!';
|
||||||
|
GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%';
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
|
||||||
|
```
|
||||||
|
|
||||||
|
Важно:
|
||||||
|
- не выдавайте `INSERT/UPDATE/DELETE` на `qt_pricelists` и `qt_pricelist_items`, если пользователь не должен управлять прайслистами;
|
||||||
|
- если видите ошибку `Access denied for user ...@'<ip>'`, проверьте, что не осталось других записей `quote_user@host` кроме `quote_user@'%'`;
|
||||||
|
- после смены DB-настроек через `/setup` приложение перезапускается автоматически и подхватывает нового пользователя.
|
||||||
|
|
||||||
### 4. Импорт метаданных компонентов
|
### 4. Импорт метаданных компонентов
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -120,6 +196,52 @@ 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
|
||||||
@@ -175,8 +297,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.
|
||||||
@@ -245,6 +382,9 @@ CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/qfs ./cmd/qfs
|
|||||||
| `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 |
|
||||||
|
|
||||||
## Интеграция с существующей БД
|
## Интеграция с существующей БД
|
||||||
|
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
## Release v0.2.6 - Build & Release Improvements
|
|
||||||
|
|
||||||
Minor release focused on developer experience and release automation.
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
**Build System**
|
|
||||||
- 🎯 Renamed binary from `quoteforge` to `qfs` (shorter, easier to type)
|
|
||||||
- 🏷️ Added `-version` flag to display build version
|
|
||||||
- 📦 Added Makefile with build targets for all platforms
|
|
||||||
- 🚀 Added automated release script for multi-platform binaries
|
|
||||||
|
|
||||||
**New Commands**
|
|
||||||
```bash
|
|
||||||
make build-release # Optimized build with version info
|
|
||||||
make build-all # Build for Linux + macOS (Intel/ARM)
|
|
||||||
make release # Create release packages with checksums
|
|
||||||
./bin/qfs -version # Show version
|
|
||||||
```
|
|
||||||
|
|
||||||
**Release Artifacts**
|
|
||||||
- Linux AMD64
|
|
||||||
- macOS Intel (AMD64)
|
|
||||||
- macOS Apple Silicon (ARM64)
|
|
||||||
- Windows AMD64
|
|
||||||
- SHA256 checksums
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
|
|
||||||
Download the appropriate binary for your platform:
|
|
||||||
```bash
|
|
||||||
# Linux
|
|
||||||
wget https://git.mchus.pro/mchus/QuoteForge/releases/download/v0.2.6/qfs-v0.2.6-linux-amd64.tar.gz
|
|
||||||
tar -xzf qfs-v0.2.6-linux-amd64.tar.gz
|
|
||||||
chmod +x qfs-linux-amd64
|
|
||||||
./qfs-linux-amd64
|
|
||||||
|
|
||||||
# macOS Intel
|
|
||||||
curl -L -O https://git.mchus.pro/mchus/QuoteForge/releases/download/v0.2.6/qfs-v0.2.6-darwin-amd64.tar.gz
|
|
||||||
tar -xzf qfs-v0.2.6-darwin-amd64.tar.gz
|
|
||||||
chmod +x qfs-darwin-amd64
|
|
||||||
./qfs-darwin-amd64
|
|
||||||
|
|
||||||
# macOS Apple Silicon
|
|
||||||
curl -L -O https://git.mchus.pro/mchus/QuoteForge/releases/download/v0.2.6/qfs-v0.2.6-darwin-arm64.tar.gz
|
|
||||||
tar -xzf qfs-v0.2.6-darwin-arm64.tar.gz
|
|
||||||
chmod +x qfs-darwin-arm64
|
|
||||||
./qfs-darwin-arm64
|
|
||||||
|
|
||||||
# Windows
|
|
||||||
curl -L -O https://git.mchus.pro/mchus/QuoteForge/releases/download/v0.2.6/qfs-v0.2.6-windows-amd64.zip
|
|
||||||
unzip qfs-v0.2.6-windows-amd64.zip
|
|
||||||
qfs-windows-amd64.exe
|
|
||||||
```
|
|
||||||
|
|
||||||
### Breaking Changes
|
|
||||||
None - fully backward compatible with v0.2.5
|
|
||||||
|
|
||||||
### Full Changelog
|
|
||||||
https://git.mchus.pro/mchus/QuoteForge/compare/v0.2.5...v0.2.6
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
## Release v0.2.7 - Windows Support & Localhost Binding
|
|
||||||
|
|
||||||
Bug fix release improving Windows compatibility and default network binding.
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
**Windows Support**
|
|
||||||
- ✅ Fixed template loading errors on Windows
|
|
||||||
- ✅ All file paths now use `filepath.Join` for cross-platform compatibility
|
|
||||||
- ✅ Binary now works correctly on Windows without path errors
|
|
||||||
|
|
||||||
**Localhost Binding**
|
|
||||||
- ✅ Server now binds to `127.0.0.1` by default (instead of `0.0.0.0`)
|
|
||||||
- ✅ Browser always opens to `http://127.0.0.1:8080`
|
|
||||||
- ✅ Setup mode properly opens browser automatically
|
|
||||||
- ✅ More secure default - only accessible from local machine
|
|
||||||
|
|
||||||
> **Note:** To bind to all network interfaces (for network access), set `host: "0.0.0.0"` in config.yaml
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
|
|
||||||
Download the appropriate binary for your platform:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Linux
|
|
||||||
wget https://git.mchus.pro/mchus/QuoteForge/releases/download/v0.2.7/qfs-v0.2.7-linux-amd64.tar.gz
|
|
||||||
tar -xzf qfs-v0.2.7-linux-amd64.tar.gz
|
|
||||||
chmod +x qfs-linux-amd64
|
|
||||||
./qfs-linux-amd64
|
|
||||||
|
|
||||||
# macOS Intel
|
|
||||||
curl -L -O https://git.mchus.pro/mchus/QuoteForge/releases/download/v0.2.7/qfs-v0.2.7-darwin-amd64.tar.gz
|
|
||||||
tar -xzf qfs-v0.2.7-darwin-amd64.tar.gz
|
|
||||||
chmod +x qfs-darwin-amd64
|
|
||||||
./qfs-darwin-amd64
|
|
||||||
|
|
||||||
# macOS Apple Silicon
|
|
||||||
curl -L -O https://git.mchus.pro/mchus/QuoteForge/releases/download/v0.2.7/qfs-v0.2.7-darwin-arm64.tar.gz
|
|
||||||
tar -xzf qfs-v0.2.7-darwin-arm64.tar.gz
|
|
||||||
chmod +x qfs-darwin-arm64
|
|
||||||
./qfs-darwin-arm64
|
|
||||||
|
|
||||||
# Windows
|
|
||||||
# Download and extract: https://git.mchus.pro/mchus/QuoteForge/releases/download/v0.2.7/qfs-v0.2.7-windows-amd64.zip
|
|
||||||
# Run: qfs-windows-amd64.exe
|
|
||||||
```
|
|
||||||
|
|
||||||
### Breaking Changes
|
|
||||||
None - fully backward compatible with v0.2.6
|
|
||||||
|
|
||||||
### Upgrade Notes
|
|
||||||
- If you use config.yaml with `host: "0.0.0.0"`, the app will respect that setting
|
|
||||||
- Default behavior now binds only to localhost for security
|
|
||||||
- Windows users no longer need workarounds for path errors
|
|
||||||
|
|
||||||
### Full Changelog
|
|
||||||
https://git.mchus.pro/mchus/QuoteForge/compare/v0.2.6...v0.2.7
|
|
||||||
21
assets_embed.go
Normal file
21
assets_embed.go
Normal 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")
|
||||||
|
}
|
||||||
@@ -81,4 +81,4 @@ func main() {
|
|||||||
log.Println(" - reset-counters: Reset usage counters")
|
log.Println(" - reset-counters: Reset usage counters")
|
||||||
log.Println(" - update-popularity: Update popularity scores")
|
log.Println(" - update-popularity: Update popularity scores")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
@@ -110,20 +115,22 @@ func main() {
|
|||||||
// Create local configuration
|
// Create local configuration
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
localConfig := &localdb.LocalConfiguration{
|
localConfig := &localdb.LocalConfiguration{
|
||||||
UUID: c.UUID,
|
UUID: c.UUID,
|
||||||
ServerID: &c.ID,
|
ServerID: &c.ID,
|
||||||
Name: c.Name,
|
ProjectUUID: c.ProjectUUID,
|
||||||
Items: localItems,
|
Name: c.Name,
|
||||||
TotalPrice: c.TotalPrice,
|
Items: localItems,
|
||||||
CustomPrice: c.CustomPrice,
|
TotalPrice: c.TotalPrice,
|
||||||
Notes: c.Notes,
|
CustomPrice: c.CustomPrice,
|
||||||
IsTemplate: c.IsTemplate,
|
Notes: c.Notes,
|
||||||
ServerCount: c.ServerCount,
|
IsTemplate: c.IsTemplate,
|
||||||
CreatedAt: c.CreatedAt,
|
ServerCount: c.ServerCount,
|
||||||
UpdatedAt: now,
|
CreatedAt: c.CreatedAt,
|
||||||
SyncedAt: &now,
|
UpdatedAt: now,
|
||||||
SyncStatus: "synced",
|
SyncedAt: &now,
|
||||||
OriginalUserID: c.UserID,
|
SyncStatus: "synced",
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
283
cmd/migrate_ops_projects/main.go
Normal file
283
cmd/migrate_ops_projects/main.go
Normal 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
|
||||||
|
}
|
||||||
714
cmd/qfs/main.go
714
cmd/qfs/main.go
@@ -2,20 +2,27 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
qfassets "git.mchus.pro/mchus/quoteforge"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||||
|
"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/db"
|
"git.mchus.pro/mchus/quoteforge/internal/db"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/handlers"
|
"git.mchus.pro/mchus/quoteforge/internal/handlers"
|
||||||
@@ -28,20 +35,20 @@ import (
|
|||||||
"git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
|
"git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
|
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||||
|
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
localDBPath = "./data/settings.db"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Version is set via ldflags during build
|
// Version is set via ldflags during build
|
||||||
var Version = "dev"
|
var Version = "dev"
|
||||||
|
|
||||||
|
const backgroundSyncInterval = 5 * time.Minute
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
configPath := flag.String("config", "config.yaml", "path to config file (optional, for server settings)")
|
configPath := flag.String("config", "", "path to config file (default: user state dir or QFS_CONFIG_PATH)")
|
||||||
|
localDBPath := flag.String("localdb", "", "path to local SQLite database (default: user state dir or QFS_DB_PATH)")
|
||||||
migrate := flag.Bool("migrate", false, "run database migrations")
|
migrate := flag.Bool("migrate", false, "run database migrations")
|
||||||
version := flag.Bool("version", false, "show version information")
|
version := flag.Bool("version", false, "show version information")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
@@ -52,8 +59,48 @@ func main() {
|
|||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
exePath, _ := os.Executable()
|
||||||
|
slog.Info("starting qfs", "version", Version, "executable", exePath)
|
||||||
|
appmeta.SetVersion(Version)
|
||||||
|
|
||||||
|
resolvedConfigPath, err := appstate.ResolveConfigPath(*configPath)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to resolve config path", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvedLocalDBPath, err := appstate.ResolveDBPath(*localDBPath)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to resolve local database path", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate legacy project-local config path to the user state directory when using defaults.
|
||||||
|
if *configPath == "" && os.Getenv("QFS_CONFIG_PATH") == "" {
|
||||||
|
migratedFrom, migrateErr := appstate.MigrateLegacyFile(resolvedConfigPath, []string{"config.yaml"})
|
||||||
|
if migrateErr != nil {
|
||||||
|
slog.Warn("failed to migrate legacy config file", "error", migrateErr)
|
||||||
|
} else if migratedFrom != "" {
|
||||||
|
slog.Info("migrated legacy config file", "from", migratedFrom, "to", resolvedConfigPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate legacy project-local DB path to the user state directory when using defaults.
|
||||||
|
if *localDBPath == "" && os.Getenv("QFS_DB_PATH") == "" {
|
||||||
|
legacyPaths := []string{
|
||||||
|
filepath.Join("data", "settings.db"),
|
||||||
|
filepath.Join("data", "qfs.db"),
|
||||||
|
}
|
||||||
|
migratedFrom, migrateErr := appstate.MigrateLegacyDB(resolvedLocalDBPath, legacyPaths)
|
||||||
|
if migrateErr != nil {
|
||||||
|
slog.Warn("failed to migrate legacy local database", "error", migrateErr)
|
||||||
|
} else if migratedFrom != "" {
|
||||||
|
slog.Info("migrated legacy local database", "from", migratedFrom, "to", resolvedLocalDBPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize local SQLite database (always used)
|
// Initialize local SQLite database (always used)
|
||||||
local, err := localdb.New(localDBPath)
|
local, err := localdb.New(resolvedLocalDBPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to initialize local database", "error", err)
|
slog.Error("failed to initialize local database", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -67,13 +114,19 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load config for server settings (optional)
|
// Load config for server settings (optional)
|
||||||
cfg, err := config.Load(*configPath)
|
cfg, err := config.Load(resolvedConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Use defaults if config file doesn't exist
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
slog.Info("config file not found, using defaults", "path", *configPath)
|
// Use defaults if config file doesn't exist
|
||||||
cfg = &config.Config{}
|
slog.Info("config file not found, using defaults", "path", resolvedConfigPath)
|
||||||
|
cfg = &config.Config{}
|
||||||
|
} else {
|
||||||
|
slog.Error("failed to load config", "path", resolvedConfigPath, "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setConfigDefaults(cfg)
|
setConfigDefaults(cfg)
|
||||||
|
slog.Info("resolved runtime files", "config_path", resolvedConfigPath, "localdb_path", resolvedLocalDBPath)
|
||||||
|
|
||||||
setupLogger(cfg.Logging)
|
setupLogger(cfg.Logging)
|
||||||
|
|
||||||
@@ -81,7 +134,6 @@ func main() {
|
|||||||
connMgr := db.NewConnectionManager(local)
|
connMgr := db.NewConnectionManager(local)
|
||||||
|
|
||||||
dbUser := local.GetDBUser()
|
dbUser := local.GetDBUser()
|
||||||
dbUserID := uint(1)
|
|
||||||
|
|
||||||
// Try to connect to MariaDB on startup
|
// Try to connect to MariaDB on startup
|
||||||
mariaDB, err := connMgr.GetDB()
|
mariaDB, err := connMgr.GetDB()
|
||||||
@@ -90,19 +142,13 @@ func main() {
|
|||||||
mariaDB = nil
|
mariaDB = nil
|
||||||
} else {
|
} else {
|
||||||
slog.Info("successfully connected to MariaDB on startup")
|
slog.Info("successfully connected to MariaDB on startup")
|
||||||
// Ensure DB user exists and get their ID
|
|
||||||
if dbUserID, err = models.EnsureDBUser(mariaDB, dbUser); err != nil {
|
|
||||||
slog.Error("failed to ensure DB user", "error", err)
|
|
||||||
// Continue with default ID
|
|
||||||
dbUserID = uint(1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("starting QuoteForge server",
|
slog.Info("starting QuoteForge server",
|
||||||
|
"version", Version,
|
||||||
"host", cfg.Server.Host,
|
"host", cfg.Server.Host,
|
||||||
"port", cfg.Server.Port,
|
"port", cfg.Server.Port,
|
||||||
"db_user", dbUser,
|
"db_user", dbUser,
|
||||||
"db_user_id", dbUserID,
|
|
||||||
"online", mariaDB != nil,
|
"online", mariaDB != nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -123,8 +169,39 @@ func main() {
|
|||||||
slog.Info("migrations completed")
|
slog.Info("migrations completed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always apply SQL migrations on startup when database is available.
|
||||||
|
// This keeps schema in sync for long-running installations without manual steps.
|
||||||
|
// If current DB user does not have enough privileges, continue startup in normal mode.
|
||||||
|
if mariaDB != nil {
|
||||||
|
sqlMigrationsPath := filepath.Join("migrations")
|
||||||
|
needsMigrations, err := models.NeedsSQLMigrations(mariaDB, sqlMigrationsPath)
|
||||||
|
if err != nil {
|
||||||
|
if models.IsMigrationPermissionError(err) {
|
||||||
|
slog.Info("startup SQL migrations skipped: insufficient database privileges", "path", sqlMigrationsPath, "error", err)
|
||||||
|
} else {
|
||||||
|
slog.Error("startup SQL migrations check failed", "path", sqlMigrationsPath, "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
} else if needsMigrations {
|
||||||
|
if err := models.RunSQLMigrations(mariaDB, sqlMigrationsPath); err != nil {
|
||||||
|
if models.IsMigrationPermissionError(err) {
|
||||||
|
slog.Info("startup SQL migrations skipped: insufficient database privileges", "path", sqlMigrationsPath, "error", err)
|
||||||
|
} else {
|
||||||
|
slog.Error("startup SQL migrations failed", "path", sqlMigrationsPath, "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
slog.Info("startup SQL migrations applied", "path", sqlMigrationsPath)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
slog.Debug("startup SQL migrations not needed", "path", sqlMigrationsPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
gin.SetMode(cfg.Server.Mode)
|
gin.SetMode(cfg.Server.Mode)
|
||||||
router, syncService, err := setupRouter(cfg, local, connMgr, mariaDB, dbUserID)
|
restartSig := make(chan struct{}, 1)
|
||||||
|
|
||||||
|
router, syncService, err := setupRouter(cfg, local, connMgr, mariaDB, dbUser, restartSig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to setup router", "error", err)
|
slog.Error("failed to setup router", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -134,7 +211,7 @@ func main() {
|
|||||||
workerCtx, workerCancel := context.WithCancel(context.Background())
|
workerCtx, workerCancel := context.WithCancel(context.Background())
|
||||||
defer workerCancel()
|
defer workerCancel()
|
||||||
|
|
||||||
syncWorker := sync.NewWorker(syncService, connMgr, 5*time.Minute)
|
syncWorker := sync.NewWorker(syncService, connMgr, backgroundSyncInterval)
|
||||||
go syncWorker.Start(workerCtx)
|
go syncWorker.Start(workerCtx)
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
@@ -166,9 +243,15 @@ func main() {
|
|||||||
|
|
||||||
quit := make(chan os.Signal, 1)
|
quit := make(chan os.Signal, 1)
|
||||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
<-quit
|
|
||||||
|
|
||||||
slog.Info("shutting down server...")
|
shouldRestart := false
|
||||||
|
select {
|
||||||
|
case <-quit:
|
||||||
|
slog.Info("shutting down server...")
|
||||||
|
case <-restartSig:
|
||||||
|
shouldRestart = true
|
||||||
|
slog.Info("restarting application after connection settings update...")
|
||||||
|
}
|
||||||
|
|
||||||
// Stop background sync worker first
|
// Stop background sync worker first
|
||||||
syncWorker.Stop()
|
syncWorker.Stop()
|
||||||
@@ -183,6 +266,10 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("server stopped")
|
slog.Info("server stopped")
|
||||||
|
|
||||||
|
if shouldRestart {
|
||||||
|
restartProcess()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setConfigDefaults(cfg *config.Config) {
|
func setConfigDefaults(cfg *config.Config) {
|
||||||
@@ -238,7 +325,11 @@ func runSetupMode(local *localdb.LocalDB) {
|
|||||||
router.Use(gin.Recovery())
|
router.Use(gin.Recovery())
|
||||||
|
|
||||||
staticPath := filepath.Join("web", "static")
|
staticPath := filepath.Join("web", "static")
|
||||||
router.Static("/static", staticPath)
|
if stat, err := os.Stat(staticPath); err == nil && stat.IsDir() {
|
||||||
|
router.Static("/static", staticPath)
|
||||||
|
} else if staticFS, err := qfassets.StaticFS(); err == nil {
|
||||||
|
router.StaticFS("/static", http.FS(staticFS))
|
||||||
|
}
|
||||||
|
|
||||||
// Setup routes only
|
// Setup routes only
|
||||||
router.GET("/", func(c *gin.Context) {
|
router.GET("/", func(c *gin.Context) {
|
||||||
@@ -258,7 +349,7 @@ func runSetupMode(local *localdb.LocalDB) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
addr := "127.0.0.1:8080"
|
addr := "127.0.0.1:8080"
|
||||||
slog.Info("starting setup mode server", "address", addr)
|
slog.Info("starting setup mode server", "address", addr, "version", Version)
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
@@ -349,7 +440,7 @@ func setupDatabaseFromDSN(dsn string) (*gorm.DB, error) {
|
|||||||
return db, nil
|
return db, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.ConnectionManager, mariaDB *gorm.DB, dbUserID uint) (*gin.Engine, *sync.Service, error) {
|
func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.ConnectionManager, mariaDB *gorm.DB, dbUsername string, restartSig chan struct{}) (*gin.Engine, *sync.Service, error) {
|
||||||
// mariaDB may be nil if we're in offline mode
|
// mariaDB may be nil if we're in offline mode
|
||||||
|
|
||||||
// Repositories
|
// Repositories
|
||||||
@@ -381,6 +472,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
var alertService *alerts.Service
|
var alertService *alerts.Service
|
||||||
var pricelistService *pricelist.Service
|
var pricelistService *pricelist.Service
|
||||||
var syncService *sync.Service
|
var syncService *sync.Service
|
||||||
|
var projectService *services.ProjectService
|
||||||
|
|
||||||
// Sync service always uses ConnectionManager (works offline and online)
|
// Sync service always uses ConnectionManager (works offline and online)
|
||||||
syncService = sync.NewService(connMgr, local)
|
syncService = sync.NewService(connMgr, local)
|
||||||
@@ -391,7 +483,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
quoteService = services.NewQuoteService(componentRepo, statsRepo, pricingService)
|
quoteService = services.NewQuoteService(componentRepo, statsRepo, pricingService)
|
||||||
exportService = services.NewExportService(cfg.Export, categoryRepo)
|
exportService = services.NewExportService(cfg.Export, categoryRepo)
|
||||||
alertService = alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
|
alertService = alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
|
||||||
pricelistService = pricelist.NewService(mariaDB, pricelistRepo, componentRepo)
|
pricelistService = pricelist.NewService(mariaDB, pricelistRepo, componentRepo, pricingService)
|
||||||
} else {
|
} else {
|
||||||
// In offline mode, we still need to create services that don't require DB
|
// In offline mode, we still need to create services that don't require DB
|
||||||
pricingService = pricing.NewService(nil, nil, cfg.Pricing)
|
pricingService = pricing.NewService(nil, nil, cfg.Pricing)
|
||||||
@@ -399,7 +491,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
quoteService = services.NewQuoteService(nil, nil, pricingService)
|
quoteService = services.NewQuoteService(nil, nil, pricingService)
|
||||||
exportService = services.NewExportService(cfg.Export, nil)
|
exportService = services.NewExportService(cfg.Export, nil)
|
||||||
alertService = alerts.NewService(nil, nil, nil, nil, cfg.Alerts, cfg.Pricing)
|
alertService = alerts.NewService(nil, nil, nil, nil, cfg.Alerts, cfg.Pricing)
|
||||||
pricelistService = pricelist.NewService(nil, nil, nil)
|
pricelistService = pricelist.NewService(nil, nil, nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// isOnline function for local-first architecture
|
// isOnline function for local-first architecture
|
||||||
@@ -408,8 +500,81 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Local-first configuration service (replaces old ConfigurationService)
|
// Local-first configuration service (replaces old ConfigurationService)
|
||||||
|
projectService = services.NewProjectService(local)
|
||||||
configService := services.NewLocalConfigurationService(local, syncService, quoteService, isOnline)
|
configService := services.NewLocalConfigurationService(local, syncService, quoteService, isOnline)
|
||||||
|
|
||||||
|
// Data hygiene: remove empty nameless projects and ensure every configuration is attached to a project.
|
||||||
|
if removed, err := local.ConsolidateSystemProjects(); err == nil && removed > 0 {
|
||||||
|
slog.Info("consolidated duplicate local system projects", "removed", removed)
|
||||||
|
}
|
||||||
|
if removed, err := local.PurgeEmptyNamelessProjects(); err == nil && removed > 0 {
|
||||||
|
slog.Info("purged empty nameless local projects", "removed", removed)
|
||||||
|
}
|
||||||
|
if err := local.BackfillConfigurationProjects(dbUsername); err != nil {
|
||||||
|
slog.Warn("failed to backfill local configuration projects", "error", err)
|
||||||
|
}
|
||||||
|
if mariaDB != nil {
|
||||||
|
serverProjectRepo := repository.NewProjectRepository(mariaDB)
|
||||||
|
if removed, err := serverProjectRepo.PurgeEmptyNamelessProjects(); err == nil && removed > 0 {
|
||||||
|
slog.Info("purged empty nameless server projects", "removed", removed)
|
||||||
|
}
|
||||||
|
if err := serverProjectRepo.EnsureSystemProjectsAndBackfillConfigurations(); err != nil {
|
||||||
|
slog.Warn("failed to backfill server configuration projects", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
syncProjectsFromServer := func() {
|
||||||
|
if !connMgr.IsOnline() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serverDB, err := connMgr.GetDB()
|
||||||
|
if err != nil || serverDB == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
projectRepo := repository.NewProjectRepository(serverDB)
|
||||||
|
serverProjects, _, err := projectRepo.List(0, 10000, true)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
for i := range serverProjects {
|
||||||
|
sp := serverProjects[i]
|
||||||
|
localProject, getErr := local.GetProjectByUUID(sp.UUID)
|
||||||
|
if getErr == nil && localProject != nil {
|
||||||
|
// Keep unsynced local changes intact.
|
||||||
|
if localProject.SyncStatus == "pending" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
localProject.OwnerUsername = sp.OwnerUsername
|
||||||
|
localProject.Name = sp.Name
|
||||||
|
localProject.IsActive = sp.IsActive
|
||||||
|
localProject.IsSystem = sp.IsSystem
|
||||||
|
localProject.CreatedAt = sp.CreatedAt
|
||||||
|
localProject.UpdatedAt = sp.UpdatedAt
|
||||||
|
serverID := sp.ID
|
||||||
|
localProject.ServerID = &serverID
|
||||||
|
localProject.SyncStatus = "synced"
|
||||||
|
localProject.SyncedAt = &now
|
||||||
|
_ = local.SaveProject(localProject)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
lp := localdb.ProjectToLocal(&sp)
|
||||||
|
lp.SyncStatus = "synced"
|
||||||
|
lp.SyncedAt = &now
|
||||||
|
_ = local.SaveProject(lp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
syncConfigurationsFromServer := func() {
|
||||||
|
if !connMgr.IsOnline() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = configService.ImportFromServer()
|
||||||
|
}
|
||||||
|
|
||||||
// Use filepath.Join for cross-platform path compatibility
|
// Use filepath.Join for cross-platform path compatibility
|
||||||
templatesPath := filepath.Join("web", "templates")
|
templatesPath := filepath.Join("web", "templates")
|
||||||
|
|
||||||
@@ -419,13 +584,13 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
exportHandler := handlers.NewExportHandler(exportService, configService, componentService)
|
exportHandler := handlers.NewExportHandler(exportService, configService, componentService)
|
||||||
pricingHandler := handlers.NewPricingHandler(mariaDB, pricingService, alertService, componentRepo, priceRepo, statsRepo)
|
pricingHandler := handlers.NewPricingHandler(mariaDB, pricingService, alertService, componentRepo, priceRepo, statsRepo)
|
||||||
pricelistHandler := handlers.NewPricelistHandler(pricelistService, local)
|
pricelistHandler := handlers.NewPricelistHandler(pricelistService, local)
|
||||||
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath)
|
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("creating sync handler: %w", err)
|
return nil, nil, fmt.Errorf("creating sync handler: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup handler (for reconfiguration) - no restart signal in normal mode
|
// Setup handler (for reconfiguration)
|
||||||
setupHandler, err := handlers.NewSetupHandler(local, connMgr, templatesPath, nil)
|
setupHandler, err := handlers.NewSetupHandler(local, connMgr, templatesPath, restartSig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("creating setup handler: %w", err)
|
return nil, nil, fmt.Errorf("creating setup handler: %w", err)
|
||||||
}
|
}
|
||||||
@@ -445,7 +610,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
|
|
||||||
// Static files (use filepath.Join for Windows compatibility)
|
// Static files (use filepath.Join for Windows compatibility)
|
||||||
staticPath := filepath.Join("web", "static")
|
staticPath := filepath.Join("web", "static")
|
||||||
router.Static("/static", staticPath)
|
if stat, err := os.Stat(staticPath); err == nil && stat.IsDir() {
|
||||||
|
router.Static("/static", staticPath)
|
||||||
|
} else if staticFS, err := qfassets.StaticFS(); err == nil {
|
||||||
|
router.StaticFS("/static", http.FS(staticFS))
|
||||||
|
}
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
router.GET("/health", func(c *gin.Context) {
|
router.GET("/health", func(c *gin.Context) {
|
||||||
@@ -520,6 +689,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
router.GET("/", webHandler.Index)
|
router.GET("/", webHandler.Index)
|
||||||
router.GET("/configs", webHandler.Configs)
|
router.GET("/configs", webHandler.Configs)
|
||||||
router.GET("/configurator", webHandler.Configurator)
|
router.GET("/configurator", webHandler.Configurator)
|
||||||
|
router.GET("/projects", webHandler.Projects)
|
||||||
|
router.GET("/projects/:uuid", webHandler.ProjectDetail)
|
||||||
router.GET("/pricelists", func(c *gin.Context) {
|
router.GET("/pricelists", func(c *gin.Context) {
|
||||||
// Redirect to admin/pricing with pricelists tab
|
// Redirect to admin/pricing with pricelists tab
|
||||||
c.Redirect(http.StatusFound, "/admin/pricing?tab=pricelists")
|
c.Redirect(http.StatusFound, "/admin/pricing?tab=pricelists")
|
||||||
@@ -573,6 +744,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
pricelists.GET("/:id", pricelistHandler.Get)
|
pricelists.GET("/:id", pricelistHandler.Get)
|
||||||
pricelists.GET("/:id/items", pricelistHandler.GetItems)
|
pricelists.GET("/:id/items", pricelistHandler.GetItems)
|
||||||
pricelists.POST("", pricelistHandler.Create)
|
pricelists.POST("", pricelistHandler.Create)
|
||||||
|
pricelists.POST("/create-with-progress", pricelistHandler.CreateWithProgress)
|
||||||
|
pricelists.PATCH("/:id/active", pricelistHandler.SetActive)
|
||||||
pricelists.DELETE("/:id", pricelistHandler.Delete)
|
pricelists.DELETE("/:id", pricelistHandler.Delete)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,10 +753,18 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
configs := api.Group("/configs")
|
configs := api.Group("/configs")
|
||||||
{
|
{
|
||||||
configs.GET("", func(c *gin.Context) {
|
configs.GET("", func(c *gin.Context) {
|
||||||
|
syncConfigurationsFromServer()
|
||||||
|
|
||||||
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"))
|
||||||
|
status := c.DefaultQuery("status", "active")
|
||||||
|
search := c.Query("search")
|
||||||
|
if status != "active" && status != "archived" && status != "all" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
cfgs, total, err := configService.ListAll(page, perPage)
|
cfgs, total, err := configService.ListAllWithStatus(page, perPage, status, search)
|
||||||
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
|
||||||
@@ -594,9 +775,24 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
"total": total,
|
"total": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"per_page": perPage,
|
"per_page": perPage,
|
||||||
|
"status": status,
|
||||||
|
"search": search,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
configs.POST("/import", func(c *gin.Context) {
|
||||||
|
result, err := configService.ImportFromServer()
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sync.ErrOffline) {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database is offline"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
})
|
||||||
|
|
||||||
configs.POST("", func(c *gin.Context) {
|
configs.POST("", func(c *gin.Context) {
|
||||||
var req services.CreateConfigRequest
|
var req services.CreateConfigRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
@@ -604,7 +800,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
config, err := configService.Create(dbUserID, &req) // use DB user ID
|
config, err := configService.Create(dbUsername, &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
|
||||||
@@ -646,7 +842,20 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
c.JSON(http.StatusOK, gin.H{"message": "archived"})
|
||||||
|
})
|
||||||
|
|
||||||
|
configs.POST("/:uuid/reactivate", func(c *gin.Context) {
|
||||||
|
uuid := c.Param("uuid")
|
||||||
|
config, err := configService.ReactivateNoAuth(uuid)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "reactivated",
|
||||||
|
"config": config,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
configs.PATCH("/:uuid/rename", func(c *gin.Context) {
|
configs.PATCH("/:uuid/rename", func(c *gin.Context) {
|
||||||
@@ -678,7 +887,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
config, err := configService.CloneNoAuth(uuid, req.Name, dbUserID)
|
config, err := configService.CloneNoAuth(uuid, req.Name, dbUsername)
|
||||||
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
|
||||||
@@ -696,6 +905,438 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, config)
|
c.JSON(http.StatusOK, config)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
configs.PATCH("/:uuid/project", func(c *gin.Context) {
|
||||||
|
uuid := c.Param("uuid")
|
||||||
|
var req struct {
|
||||||
|
ProjectUUID string `json:"project_uuid"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updated, err := configService.SetProjectNoAuth(uuid, req.ProjectUUID)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, services.ErrConfigNotFound):
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
case errors.Is(err, services.ErrProjectNotFound):
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
case errors.Is(err, services.ErrProjectForbidden):
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, updated)
|
||||||
|
})
|
||||||
|
|
||||||
|
configs.GET("/:uuid/versions", func(c *gin.Context) {
|
||||||
|
uuid := c.Param("uuid")
|
||||||
|
|
||||||
|
limit, err := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||||
|
if err != nil || limit <= 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid limit"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
offset, err := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||||
|
if err != nil || offset < 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid offset"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
versions, err := configService.ListVersions(uuid, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, services.ErrConfigNotFound):
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
|
||||||
|
case errors.Is(err, services.ErrInvalidVersionNumber):
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid paging params"})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"versions": versions,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
configs.GET("/:uuid/versions/:version", func(c *gin.Context) {
|
||||||
|
uuid := c.Param("uuid")
|
||||||
|
versionNo, err := strconv.Atoi(c.Param("version"))
|
||||||
|
if err != nil || versionNo <= 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version number"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
version, err := configService.GetVersion(uuid, versionNo)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, services.ErrInvalidVersionNumber):
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version number"})
|
||||||
|
case errors.Is(err, services.ErrConfigVersionNotFound):
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, version)
|
||||||
|
})
|
||||||
|
|
||||||
|
configs.POST("/:uuid/rollback", func(c *gin.Context) {
|
||||||
|
uuid := c.Param("uuid")
|
||||||
|
var req struct {
|
||||||
|
TargetVersion int `json:"target_version"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.TargetVersion <= 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_version"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := configService.RollbackToVersionWithNote(uuid, req.TargetVersion, dbUsername, req.Note)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, services.ErrInvalidVersionNumber):
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_version"})
|
||||||
|
case errors.Is(err, services.ErrConfigNotFound), errors.Is(err, services.ErrConfigVersionNotFound):
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
|
||||||
|
case errors.Is(err, services.ErrVersionConflict):
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "version conflict"})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentVersion, err := configService.GetCurrentVersion(uuid)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "rollback applied",
|
||||||
|
"config": config,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "rollback applied",
|
||||||
|
"config": config,
|
||||||
|
"current_version": currentVersion,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
projects := api.Group("/projects")
|
||||||
|
{
|
||||||
|
projects.GET("", func(c *gin.Context) {
|
||||||
|
syncProjectsFromServer()
|
||||||
|
syncConfigurationsFromServer()
|
||||||
|
|
||||||
|
status := c.DefaultQuery("status", "active")
|
||||||
|
search := strings.ToLower(strings.TrimSpace(c.Query("search")))
|
||||||
|
author := strings.ToLower(strings.TrimSpace(c.Query("author")))
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "10"))
|
||||||
|
sortField := strings.ToLower(strings.TrimSpace(c.DefaultQuery("sort", "created_at")))
|
||||||
|
sortDir := strings.ToLower(strings.TrimSpace(c.DefaultQuery("dir", "desc")))
|
||||||
|
if status != "active" && status != "archived" && status != "all" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if perPage < 1 {
|
||||||
|
perPage = 10
|
||||||
|
}
|
||||||
|
if perPage > 100 {
|
||||||
|
perPage = 100
|
||||||
|
}
|
||||||
|
if sortField != "name" && sortField != "created_at" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sort field"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sortDir != "asc" && sortDir != "desc" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sort direction"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
allProjects, err := projectService.ListByUser(dbUsername, true)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := make([]models.Project, 0, len(allProjects))
|
||||||
|
for i := range allProjects {
|
||||||
|
p := allProjects[i]
|
||||||
|
if status == "active" && !p.IsActive {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if status == "archived" && p.IsActive {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if search != "" && !strings.Contains(strings.ToLower(p.Name), search) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if author != "" && !strings.Contains(strings.ToLower(strings.TrimSpace(p.OwnerUsername)), author) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(filtered, func(i, j int) bool {
|
||||||
|
left := filtered[i]
|
||||||
|
right := filtered[j]
|
||||||
|
if sortField == "name" {
|
||||||
|
leftName := strings.ToLower(strings.TrimSpace(left.Name))
|
||||||
|
rightName := strings.ToLower(strings.TrimSpace(right.Name))
|
||||||
|
if leftName == rightName {
|
||||||
|
if sortDir == "asc" {
|
||||||
|
return left.CreatedAt.Before(right.CreatedAt)
|
||||||
|
}
|
||||||
|
return left.CreatedAt.After(right.CreatedAt)
|
||||||
|
}
|
||||||
|
if sortDir == "asc" {
|
||||||
|
return leftName < rightName
|
||||||
|
}
|
||||||
|
return leftName > rightName
|
||||||
|
}
|
||||||
|
if left.CreatedAt.Equal(right.CreatedAt) {
|
||||||
|
leftName := strings.ToLower(strings.TrimSpace(left.Name))
|
||||||
|
rightName := strings.ToLower(strings.TrimSpace(right.Name))
|
||||||
|
if sortDir == "asc" {
|
||||||
|
return leftName < rightName
|
||||||
|
}
|
||||||
|
return leftName > rightName
|
||||||
|
}
|
||||||
|
if sortDir == "asc" {
|
||||||
|
return left.CreatedAt.Before(right.CreatedAt)
|
||||||
|
}
|
||||||
|
return left.CreatedAt.After(right.CreatedAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
total := len(filtered)
|
||||||
|
totalPages := 0
|
||||||
|
if total > 0 {
|
||||||
|
totalPages = int(math.Ceil(float64(total) / float64(perPage)))
|
||||||
|
}
|
||||||
|
if totalPages > 0 && page > totalPages {
|
||||||
|
page = totalPages
|
||||||
|
}
|
||||||
|
|
||||||
|
start := (page - 1) * perPage
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
end := start + perPage
|
||||||
|
if end > total {
|
||||||
|
end = total
|
||||||
|
}
|
||||||
|
|
||||||
|
paged := []models.Project{}
|
||||||
|
if start < total {
|
||||||
|
paged = filtered[start:end]
|
||||||
|
}
|
||||||
|
|
||||||
|
projectRows := make([]gin.H, 0, len(paged))
|
||||||
|
for i := range paged {
|
||||||
|
p := paged[i]
|
||||||
|
configs, err := projectService.ListConfigurations(p.UUID, dbUsername, "active")
|
||||||
|
if err != nil {
|
||||||
|
configs = &services.ProjectConfigurationsResult{
|
||||||
|
ProjectUUID: p.UUID,
|
||||||
|
Configs: []models.Configuration{},
|
||||||
|
Total: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
projectRows = append(projectRows, gin.H{
|
||||||
|
"id": p.ID,
|
||||||
|
"uuid": p.UUID,
|
||||||
|
"owner_username": p.OwnerUsername,
|
||||||
|
"name": p.Name,
|
||||||
|
"is_active": p.IsActive,
|
||||||
|
"is_system": p.IsSystem,
|
||||||
|
"created_at": p.CreatedAt,
|
||||||
|
"updated_at": p.UpdatedAt,
|
||||||
|
"config_count": len(configs.Configs),
|
||||||
|
"total": configs.Total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"projects": projectRows,
|
||||||
|
"status": status,
|
||||||
|
"search": search,
|
||||||
|
"author": author,
|
||||||
|
"sort": sortField,
|
||||||
|
"dir": sortDir,
|
||||||
|
"page": page,
|
||||||
|
"per_page": perPage,
|
||||||
|
"total": total,
|
||||||
|
"total_pages": totalPages,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
projects.POST("", func(c *gin.Context) {
|
||||||
|
var req services.CreateProjectRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Name) == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "project name is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
project, err := projectService.Create(dbUsername, &req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, project)
|
||||||
|
})
|
||||||
|
|
||||||
|
projects.GET("/:uuid", func(c *gin.Context) {
|
||||||
|
project, err := projectService.GetByUUID(c.Param("uuid"), dbUsername)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, services.ErrProjectNotFound):
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
case errors.Is(err, services.ErrProjectForbidden):
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, project)
|
||||||
|
})
|
||||||
|
|
||||||
|
projects.PUT("/:uuid", func(c *gin.Context) {
|
||||||
|
var req services.UpdateProjectRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Name) == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "project name is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
project, err := projectService.Update(c.Param("uuid"), dbUsername, &req)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, services.ErrProjectNotFound):
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
case errors.Is(err, services.ErrProjectForbidden):
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, project)
|
||||||
|
})
|
||||||
|
|
||||||
|
projects.POST("/:uuid/archive", func(c *gin.Context) {
|
||||||
|
if err := projectService.Archive(c.Param("uuid"), dbUsername); err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, services.ErrProjectNotFound):
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
case errors.Is(err, services.ErrProjectForbidden):
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "project archived"})
|
||||||
|
})
|
||||||
|
|
||||||
|
projects.POST("/:uuid/reactivate", func(c *gin.Context) {
|
||||||
|
if err := projectService.Reactivate(c.Param("uuid"), dbUsername); err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, services.ErrProjectNotFound):
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
case errors.Is(err, services.ErrProjectForbidden):
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "project reactivated"})
|
||||||
|
})
|
||||||
|
|
||||||
|
projects.GET("/:uuid/configs", func(c *gin.Context) {
|
||||||
|
syncConfigurationsFromServer()
|
||||||
|
|
||||||
|
status := c.DefaultQuery("status", "active")
|
||||||
|
if status != "active" && status != "archived" && status != "all" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := projectService.ListConfigurations(c.Param("uuid"), dbUsername, status)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, services.ErrProjectNotFound):
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
case errors.Is(err, services.ErrProjectForbidden):
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("X-Config-Status", status)
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
})
|
||||||
|
|
||||||
|
projects.POST("/:uuid/configs", 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
|
||||||
|
}
|
||||||
|
projectUUID := c.Param("uuid")
|
||||||
|
req.ProjectUUID = &projectUUID
|
||||||
|
|
||||||
|
config, err := configService.Create(dbUsername, &req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, config)
|
||||||
|
})
|
||||||
|
|
||||||
|
projects.POST("/:uuid/configs/:config_uuid/clone", func(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
projectUUID := c.Param("uuid")
|
||||||
|
config, err := configService.CloneNoAuthToProject(c.Param("config_uuid"), req.Name, dbUsername, &projectUUID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, config)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pricing admin (public - RBAC disabled)
|
// Pricing admin (public - RBAC disabled)
|
||||||
@@ -719,6 +1360,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
{
|
{
|
||||||
syncAPI.GET("/status", syncHandler.GetStatus)
|
syncAPI.GET("/status", syncHandler.GetStatus)
|
||||||
syncAPI.GET("/info", syncHandler.GetInfo)
|
syncAPI.GET("/info", syncHandler.GetInfo)
|
||||||
|
syncAPI.GET("/users-status", syncHandler.GetUsersStatus)
|
||||||
syncAPI.POST("/components", syncHandler.SyncComponents)
|
syncAPI.POST("/components", syncHandler.SyncComponents)
|
||||||
syncAPI.POST("/pricelists", syncHandler.SyncPricelists)
|
syncAPI.POST("/pricelists", syncHandler.SyncPricelists)
|
||||||
syncAPI.POST("/all", syncHandler.SyncAll)
|
syncAPI.POST("/all", syncHandler.SyncAll)
|
||||||
|
|||||||
327
cmd/qfs/versioning_api_test.go
Normal file
327
cmd/qfs/versioning_api_test.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
26
internal/appmeta/version.go
Normal file
26
internal/appmeta/version.go
Normal 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
197
internal/appstate/path.go
Normal 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()
|
||||||
|
}
|
||||||
@@ -2,9 +2,12 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,8 +42,18 @@ type DatabaseConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *DatabaseConfig) DSN() string {
|
func (d *DatabaseConfig) DSN() string {
|
||||||
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
cfg := mysqlDriver.NewConfig()
|
||||||
d.User, d.Password, d.Host, d.Port, d.Name)
|
cfg.User = d.User
|
||||||
|
cfg.Passwd = d.Password
|
||||||
|
cfg.Net = "tcp"
|
||||||
|
cfg.Addr = net.JoinHostPort(d.Host, strconv.Itoa(d.Port))
|
||||||
|
cfg.DBName = d.Name
|
||||||
|
cfg.ParseTime = true
|
||||||
|
cfg.Loc = time.Local
|
||||||
|
cfg.Params = map[string]string{
|
||||||
|
"charset": "utf8mb4",
|
||||||
|
}
|
||||||
|
return cfg.FormatDSN()
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthConfig struct {
|
type AuthConfig struct {
|
||||||
|
|||||||
@@ -32,14 +32,14 @@ type ConnectionStatus struct {
|
|||||||
|
|
||||||
// ConnectionManager manages database connections with thread-safety and connection pooling
|
// ConnectionManager manages database connections with thread-safety and connection pooling
|
||||||
type ConnectionManager struct {
|
type ConnectionManager struct {
|
||||||
localDB *localdb.LocalDB // for getting DSN from settings
|
localDB *localdb.LocalDB // for getting DSN from settings
|
||||||
mu sync.RWMutex // protects db and state
|
mu sync.RWMutex // protects db and state
|
||||||
db *gorm.DB // current connection (nil if not connected)
|
db *gorm.DB // current connection (nil if not connected)
|
||||||
lastError error // last connection error
|
lastError error // last connection error
|
||||||
lastCheck time.Time // time of last check/attempt
|
lastCheck time.Time // time of last check/attempt
|
||||||
connectTimeout time.Duration // timeout for connection (default: 5s)
|
connectTimeout time.Duration // timeout for connection (default: 5s)
|
||||||
pingInterval time.Duration // minimum interval between pings (default: 30s)
|
pingInterval time.Duration // minimum interval between pings (default: 30s)
|
||||||
reconnectCooldown time.Duration // pause after failed attempt (default: 10s)
|
reconnectCooldown time.Duration // pause after failed attempt (default: 10s)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewConnectionManager creates a new ConnectionManager instance
|
// NewConnectionManager creates a new ConnectionManager instance
|
||||||
@@ -94,6 +94,8 @@ func (cm *ConnectionManager) GetDB() (*gorm.DB, error) {
|
|||||||
// Attempt to connect
|
// Attempt to connect
|
||||||
err := cm.connect()
|
err := cm.connect()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Drop stale handle so callers don't treat it as an active connection.
|
||||||
|
cm.db = nil
|
||||||
cm.lastError = err
|
cm.lastError = err
|
||||||
cm.lastCheck = time.Now()
|
cm.lastCheck = time.Now()
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -147,23 +149,27 @@ func (cm *ConnectionManager) connect() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsOnline checks if the database is currently connected and responsive
|
// IsOnline checks if the database is currently connected and responsive.
|
||||||
// Does not attempt to reconnect, only checks current state with caching
|
// If disconnected, it tries to reconnect (respecting cooldowns in GetDB).
|
||||||
func (cm *ConnectionManager) IsOnline() bool {
|
func (cm *ConnectionManager) IsOnline() bool {
|
||||||
cm.mu.RLock()
|
cm.mu.RLock()
|
||||||
if cm.db == nil {
|
isDisconnected := cm.db == nil
|
||||||
cm.mu.RUnlock()
|
lastErr := cm.lastError
|
||||||
return false
|
checkedRecently := time.Since(cm.lastCheck) < cm.pingInterval
|
||||||
}
|
|
||||||
|
|
||||||
// If we've checked recently, return cached result
|
|
||||||
if time.Since(cm.lastCheck) < cm.pingInterval {
|
|
||||||
cm.mu.RUnlock()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
cm.mu.RUnlock()
|
cm.mu.RUnlock()
|
||||||
|
|
||||||
// Need to perform actual ping
|
// Try reconnect in disconnected state.
|
||||||
|
if isDisconnected {
|
||||||
|
_, err := cm.GetDB()
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we've checked recently, return cached result.
|
||||||
|
if checkedRecently {
|
||||||
|
return lastErr == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Need to perform actual ping.
|
||||||
cm.mu.Lock()
|
cm.mu.Lock()
|
||||||
defer cm.mu.Unlock()
|
defer cm.mu.Unlock()
|
||||||
|
|
||||||
@@ -282,7 +288,7 @@ func extractHostFromDSN(dsn string) string {
|
|||||||
}
|
}
|
||||||
if parenEnd != -1 {
|
if parenEnd != -1 {
|
||||||
// Extract host:port part between tcp( and )
|
// Extract host:port part between tcp( and )
|
||||||
hostPort := dsn[tcpStart+1:parenEnd]
|
hostPort := dsn[tcpStart+1 : parenEnd]
|
||||||
return hostPort
|
return hostPort
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -317,7 +323,7 @@ func extractHostFromDSN(dsn string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if parenEnd != -1 {
|
if parenEnd != -1 {
|
||||||
hostPort := dsn[parenStart+1:parenEnd]
|
hostPort := dsn[parenStart+1 : parenEnd]
|
||||||
return hostPort
|
return hostPort
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -325,4 +331,4 @@ func extractHostFromDSN(dsn string) string {
|
|||||||
|
|
||||||
// If we can't parse it, return empty string
|
// If we can't parse it, return empty string
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ 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/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 {
|
||||||
@@ -40,7 +40,13 @@ func (h *ComponentHandler) List(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If offline mode (empty result), fallback to local components
|
// If offline mode (empty result), fallback to local components
|
||||||
if result.Total == 0 && h.localDB != nil {
|
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{
|
localFilter := localdb.ComponentFilter{
|
||||||
Category: filter.Category,
|
Category: filter.Category,
|
||||||
Search: filter.Search,
|
Search: filter.Search,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
|
"git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PricelistHandler struct {
|
type PricelistHandler struct {
|
||||||
@@ -22,8 +23,19 @@ func NewPricelistHandler(service *pricelist.Service, localDB *localdb.LocalDB) *
|
|||||||
func (h *PricelistHandler) List(c *gin.Context) {
|
func (h *PricelistHandler) List(c *gin.Context) {
|
||||||
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"))
|
||||||
|
activeOnly := c.DefaultQuery("active_only", "false") == "true"
|
||||||
|
|
||||||
pricelists, total, err := h.service.List(page, perPage)
|
var (
|
||||||
|
pricelists any
|
||||||
|
total int64
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
if activeOnly {
|
||||||
|
pricelists, total, err = h.service.ListActive(page, perPage)
|
||||||
|
} else {
|
||||||
|
pricelists, total, err = h.service.List(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
|
||||||
@@ -87,6 +99,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 == "" {
|
||||||
@@ -102,8 +123,85 @@ func (h *PricelistHandler) Create(c *gin.Context) {
|
|||||||
c.JSON(http.StatusCreated, pl)
|
c.JSON(http.StatusCreated, pl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateWithProgress creates a pricelist and streams progress updates over SSE.
|
||||||
|
func (h *PricelistHandler) CreateWithProgress(c *gin.Context) {
|
||||||
|
canWrite, debugInfo := h.service.CanWriteDebug()
|
||||||
|
if !canWrite {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{
|
||||||
|
"error": "pricelist write is not allowed",
|
||||||
|
"debug": debugInfo,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
createdBy := h.localDB.GetDBUser()
|
||||||
|
if createdBy == "" {
|
||||||
|
createdBy = "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Header("Content-Type", "text/event-stream")
|
||||||
|
c.Header("Cache-Control", "no-cache")
|
||||||
|
c.Header("Connection", "keep-alive")
|
||||||
|
c.Header("X-Accel-Buffering", "no")
|
||||||
|
|
||||||
|
flusher, ok := c.Writer.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
pl, err := h.service.CreateFromCurrentPrices(createdBy)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, pl)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sendProgress := func(payload gin.H) {
|
||||||
|
c.SSEvent("progress", payload)
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
sendProgress(gin.H{"current": 0, "total": 4, "status": "starting", "message": "Запуск..."})
|
||||||
|
pl, err := h.service.CreateFromCurrentPricesWithProgress(createdBy, func(p pricelist.CreateProgress) {
|
||||||
|
sendProgress(gin.H{
|
||||||
|
"current": p.Current,
|
||||||
|
"total": p.Total,
|
||||||
|
"status": p.Status,
|
||||||
|
"message": p.Message,
|
||||||
|
"updated": p.Updated,
|
||||||
|
"errors": p.Errors,
|
||||||
|
"lot_name": p.LotName,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
sendProgress(gin.H{
|
||||||
|
"current": 0,
|
||||||
|
"total": 4,
|
||||||
|
"status": "error",
|
||||||
|
"message": fmt.Sprintf("Ошибка: %v", err),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sendProgress(gin.H{
|
||||||
|
"current": 4,
|
||||||
|
"total": 4,
|
||||||
|
"status": "completed",
|
||||||
|
"message": "Готово",
|
||||||
|
"pricelist": pl,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Delete deletes a pricelist by ID
|
// 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 {
|
||||||
@@ -119,6 +217,40 @@ func (h *PricelistHandler) Delete(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "pricelist deleted"})
|
c.JSON(http.StatusOK, gin.H{"message": "pricelist deleted"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetActive toggles active flag on a pricelist.
|
||||||
|
func (h *PricelistHandler) SetActive(c *gin.Context) {
|
||||||
|
canWrite, debugInfo := h.service.CanWriteDebug()
|
||||||
|
if !canWrite {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{
|
||||||
|
"error": "pricelist write is not allowed",
|
||||||
|
"debug": debugInfo,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idStr := c.Param("id")
|
||||||
|
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.service.SetActive(uint(id), req.IsActive); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "updated", "is_active": req.IsActive})
|
||||||
|
}
|
||||||
|
|
||||||
// GetItems returns items for a pricelist with pagination
|
// GetItems returns items for a pricelist with pagination
|
||||||
func (h *PricelistHandler) GetItems(c *gin.Context) {
|
func (h *PricelistHandler) GetItems(c *gin.Context) {
|
||||||
idStr := c.Param("id")
|
idStr := c.Param("id")
|
||||||
|
|||||||
@@ -4,15 +4,19 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"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/db"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
"gorm.io/driver/mysql"
|
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
gormmysql "gorm.io/driver/mysql"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
)
|
)
|
||||||
@@ -34,7 +38,13 @@ func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, te
|
|||||||
|
|
||||||
// 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)
|
||||||
}
|
}
|
||||||
@@ -85,10 +95,9 @@ func (h *SetupHandler) TestConnection(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s",
|
dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second)
|
||||||
user, password, host, port, database)
|
|
||||||
|
|
||||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
db, err := gorm.Open(gormmysql.Open(dsn), &gorm.Config{
|
||||||
Logger: logger.Default.LogMode(logger.Silent),
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -140,6 +149,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")
|
||||||
@@ -159,10 +170,9 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Test connection first
|
// Test connection first
|
||||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s",
|
dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second)
|
||||||
user, password, host, port, database)
|
|
||||||
|
|
||||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
db, err := gorm.Open(gormmysql.Open(dsn), &gorm.Config{
|
||||||
Logger: logger.Default.LogMode(logger.Silent),
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -194,18 +204,29 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always restart to properly initialize all services with the new connection
|
settingsChanged := existingSettings == nil ||
|
||||||
|
existingSettings.Host != host ||
|
||||||
|
existingSettings.Port != port ||
|
||||||
|
existingSettings.Database != database ||
|
||||||
|
existingSettings.User != user ||
|
||||||
|
existingSettings.PasswordEncrypted != password
|
||||||
|
|
||||||
|
restartQueued := settingsChanged && h.restartSig != nil
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "Settings saved. Please restart the application to apply changes.",
|
"message": "Settings saved.",
|
||||||
"restart_required": true,
|
"restart_required": settingsChanged,
|
||||||
|
"restart_queued": restartQueued,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Signal restart after response is sent (if restart signal is configured)
|
// 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:
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,3 +254,19 @@ func testWritePermission(db *gorm.DB) bool {
|
|||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildMySQLDSN(host string, port int, database, user, password string, timeout time.Duration) string {
|
||||||
|
cfg := mysqlDriver.NewConfig()
|
||||||
|
cfg.User = user
|
||||||
|
cfg.Passwd = password
|
||||||
|
cfg.Net = "tcp"
|
||||||
|
cfg.Addr = net.JoinHostPort(host, strconv.Itoa(port))
|
||||||
|
cfg.DBName = database
|
||||||
|
cfg.ParseTime = true
|
||||||
|
cfg.Loc = time.Local
|
||||||
|
cfg.Timeout = timeout
|
||||||
|
cfg.Params = map[string]string{
|
||||||
|
"charset": "utf8mb4",
|
||||||
|
}
|
||||||
|
return cfg.FormatDSN()
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,50 +4,62 @@ 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/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"
|
||||||
|
"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
|
||||||
connMgr *db.ConnectionManager
|
connMgr *db.ConnectionManager
|
||||||
tmpl *template.Template
|
autoSyncInterval time.Duration
|
||||||
|
onlineGraceFactor float64
|
||||||
|
tmpl *template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSyncHandler creates a new sync handler
|
// NewSyncHandler creates a new sync handler
|
||||||
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, templatesPath string) (*SyncHandler, error) {
|
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, templatesPath string, autoSyncInterval time.Duration) (*SyncHandler, error) {
|
||||||
// Load sync_status partial template
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
return &SyncHandler{
|
return &SyncHandler{
|
||||||
localDB: localDB,
|
localDB: localDB,
|
||||||
syncService: syncService,
|
syncService: syncService,
|
||||||
connMgr: connMgr,
|
connMgr: connMgr,
|
||||||
tmpl: tmpl,
|
autoSyncInterval: autoSyncInterval,
|
||||||
|
onlineGraceFactor: 1.10,
|
||||||
|
tmpl: tmpl,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncStatusResponse represents the sync status
|
// SyncStatusResponse represents the sync status
|
||||||
type SyncStatusResponse struct {
|
type SyncStatusResponse struct {
|
||||||
LastComponentSync *time.Time `json:"last_component_sync"`
|
LastComponentSync *time.Time `json:"last_component_sync"`
|
||||||
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
|
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
|
||||||
IsOnline bool `json:"is_online"`
|
IsOnline bool `json:"is_online"`
|
||||||
ComponentsCount int64 `json:"components_count"`
|
ComponentsCount int64 `json:"components_count"`
|
||||||
PricelistsCount int64 `json:"pricelists_count"`
|
PricelistsCount int64 `json:"pricelists_count"`
|
||||||
ServerPricelists int `json:"server_pricelists"`
|
ServerPricelists int `json:"server_pricelists"`
|
||||||
NeedComponentSync bool `json:"need_component_sync"`
|
NeedComponentSync bool `json:"need_component_sync"`
|
||||||
NeedPricelistSync bool `json:"need_pricelist_sync"`
|
NeedPricelistSync bool `json:"need_pricelist_sync"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStatus returns current sync status
|
// GetStatus returns current sync status
|
||||||
@@ -79,14 +91,14 @@ func (h *SyncHandler) GetStatus(c *gin.Context) {
|
|||||||
needComponentSync := h.localDB.NeedComponentSync(24)
|
needComponentSync := h.localDB.NeedComponentSync(24)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, SyncStatusResponse{
|
c.JSON(http.StatusOK, SyncStatusResponse{
|
||||||
LastComponentSync: lastComponentSync,
|
LastComponentSync: lastComponentSync,
|
||||||
LastPricelistSync: lastPricelistSync,
|
LastPricelistSync: lastPricelistSync,
|
||||||
IsOnline: isOnline,
|
IsOnline: isOnline,
|
||||||
ComponentsCount: componentsCount,
|
ComponentsCount: componentsCount,
|
||||||
PricelistsCount: pricelistsCount,
|
PricelistsCount: pricelistsCount,
|
||||||
ServerPricelists: serverPricelists,
|
ServerPricelists: serverPricelists,
|
||||||
NeedComponentSync: needComponentSync,
|
NeedComponentSync: needComponentSync,
|
||||||
NeedPricelistSync: needPricelistSync,
|
NeedPricelistSync: needPricelistSync,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,15 +177,16 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) {
|
|||||||
Synced: synced,
|
Synced: synced,
|
||||||
Duration: time.Since(startTime).String(),
|
Duration: time.Since(startTime).String(),
|
||||||
})
|
})
|
||||||
|
h.syncService.RecordSyncHeartbeat()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncAllResponse represents result of full sync
|
// SyncAllResponse represents result of full sync
|
||||||
type SyncAllResponse struct {
|
type SyncAllResponse struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
ComponentsSynced int `json:"components_synced"`
|
ComponentsSynced int `json:"components_synced"`
|
||||||
PricelistsSynced int `json:"pricelists_synced"`
|
PricelistsSynced int `json:"pricelists_synced"`
|
||||||
Duration string `json:"duration"`
|
Duration string `json:"duration"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncAll syncs both components and pricelists
|
// SyncAll syncs both components and pricelists
|
||||||
@@ -216,8 +229,8 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("pricelist sync failed during full sync", "error", err)
|
slog.Error("pricelist sync failed during full sync", "error", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": "Pricelist sync failed: " + err.Error(),
|
"error": "Pricelist sync failed: " + err.Error(),
|
||||||
"components_synced": componentsSynced,
|
"components_synced": componentsSynced,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -230,6 +243,7 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
|||||||
PricelistsSynced: pricelistsSynced,
|
PricelistsSynced: pricelistsSynced,
|
||||||
Duration: time.Since(startTime).String(),
|
Duration: time.Since(startTime).String(),
|
||||||
})
|
})
|
||||||
|
h.syncService.RecordSyncHeartbeat()
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkOnline checks if MariaDB is accessible
|
// checkOnline checks if MariaDB is accessible
|
||||||
@@ -265,6 +279,7 @@ func (h *SyncHandler) PushPendingChanges(c *gin.Context) {
|
|||||||
Synced: pushed,
|
Synced: pushed,
|
||||||
Duration: time.Since(startTime).String(),
|
Duration: time.Since(startTime).String(),
|
||||||
})
|
})
|
||||||
|
h.syncService.RecordSyncHeartbeat()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPendingCount returns the number of pending changes
|
// GetPendingCount returns the number of pending changes
|
||||||
@@ -294,12 +309,20 @@ func (h *SyncHandler) GetPendingChanges(c *gin.Context) {
|
|||||||
|
|
||||||
// SyncInfoResponse represents sync information
|
// SyncInfoResponse represents sync information
|
||||||
type SyncInfoResponse struct {
|
type SyncInfoResponse struct {
|
||||||
LastSyncAt *time.Time `json:"last_sync_at"`
|
LastSyncAt *time.Time `json:"last_sync_at"`
|
||||||
IsOnline bool `json:"is_online"`
|
IsOnline bool `json:"is_online"`
|
||||||
ErrorCount int `json:"error_count"`
|
ErrorCount int `json:"error_count"`
|
||||||
Errors []SyncError `json:"errors,omitempty"`
|
Errors []SyncError `json:"errors,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SyncUsersStatusResponse struct {
|
||||||
|
IsOnline bool `json:"is_online"`
|
||||||
|
AutoSyncIntervalSeconds int64 `json:"auto_sync_interval_seconds"`
|
||||||
|
OnlineThresholdSeconds int64 `json:"online_threshold_seconds"`
|
||||||
|
GeneratedAt time.Time `json:"generated_at"`
|
||||||
|
Users []sync.UserSyncStatus `json:"users"`
|
||||||
|
}
|
||||||
|
|
||||||
// SyncError represents a sync error
|
// SyncError represents a sync error
|
||||||
type SyncError struct {
|
type SyncError struct {
|
||||||
Timestamp time.Time `json:"timestamp"`
|
Timestamp time.Time `json:"timestamp"`
|
||||||
@@ -356,6 +379,40 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUsersStatus returns last sync timestamps for users with sync heartbeats.
|
||||||
|
// GET /api/sync/users-status
|
||||||
|
func (h *SyncHandler) GetUsersStatus(c *gin.Context) {
|
||||||
|
threshold := time.Duration(float64(h.autoSyncInterval) * h.onlineGraceFactor)
|
||||||
|
isOnline := h.checkOnline()
|
||||||
|
|
||||||
|
if !isOnline {
|
||||||
|
c.JSON(http.StatusOK, SyncUsersStatusResponse{
|
||||||
|
IsOnline: false,
|
||||||
|
AutoSyncIntervalSeconds: int64(h.autoSyncInterval.Seconds()),
|
||||||
|
OnlineThresholdSeconds: int64(threshold.Seconds()),
|
||||||
|
GeneratedAt: time.Now().UTC(),
|
||||||
|
Users: []sync.UserSyncStatus{},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
users, err := h.syncService.ListUserSyncStatuses(threshold)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, SyncUsersStatusResponse{
|
||||||
|
IsOnline: true,
|
||||||
|
AutoSyncIntervalSeconds: int64(h.autoSyncInterval.Seconds()),
|
||||||
|
OnlineThresholdSeconds: int64(threshold.Seconds()),
|
||||||
|
GeneratedAt: time.Now().UTC(),
|
||||||
|
Users: users,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// SyncStatusPartial renders the sync status partial for htmx
|
// 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) {
|
||||||
|
|||||||
@@ -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"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,19 +18,27 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
local := &LocalConfiguration{
|
local := &LocalConfiguration{
|
||||||
UUID: cfg.UUID,
|
UUID: cfg.UUID,
|
||||||
Name: cfg.Name,
|
ProjectUUID: cfg.ProjectUUID,
|
||||||
Items: items,
|
IsActive: true,
|
||||||
TotalPrice: cfg.TotalPrice,
|
Name: cfg.Name,
|
||||||
CustomPrice: cfg.CustomPrice,
|
Items: items,
|
||||||
Notes: cfg.Notes,
|
TotalPrice: cfg.TotalPrice,
|
||||||
IsTemplate: cfg.IsTemplate,
|
CustomPrice: cfg.CustomPrice,
|
||||||
ServerCount: cfg.ServerCount,
|
Notes: cfg.Notes,
|
||||||
PriceUpdatedAt: cfg.PriceUpdatedAt,
|
IsTemplate: cfg.IsTemplate,
|
||||||
CreatedAt: cfg.CreatedAt,
|
ServerCount: cfg.ServerCount,
|
||||||
UpdatedAt: time.Now(),
|
PricelistID: cfg.PricelistID,
|
||||||
SyncStatus: "pending",
|
PriceUpdatedAt: cfg.PriceUpdatedAt,
|
||||||
OriginalUserID: cfg.UserID,
|
CreatedAt: cfg.CreatedAt,
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
SyncStatus: "pending",
|
||||||
|
OriginalUserID: derefUint(cfg.UserID),
|
||||||
|
OriginalUsername: cfg.OwnerUsername,
|
||||||
|
}
|
||||||
|
|
||||||
|
if local.OriginalUsername == "" && cfg.User != nil {
|
||||||
|
local.OriginalUsername = cfg.User.Username
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.ID > 0 {
|
if cfg.ID > 0 {
|
||||||
@@ -54,7 +62,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,
|
||||||
@@ -62,6 +71,7 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
|
|||||||
Notes: local.Notes,
|
Notes: local.Notes,
|
||||||
IsTemplate: local.IsTemplate,
|
IsTemplate: local.IsTemplate,
|
||||||
ServerCount: local.ServerCount,
|
ServerCount: local.ServerCount,
|
||||||
|
PricelistID: local.PricelistID,
|
||||||
PriceUpdatedAt: local.PriceUpdatedAt,
|
PriceUpdatedAt: local.PriceUpdatedAt,
|
||||||
CreatedAt: local.CreatedAt,
|
CreatedAt: local.CreatedAt,
|
||||||
}
|
}
|
||||||
@@ -69,10 +79,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
|
||||||
|
|||||||
72
internal/localdb/local_migrations_test.go
Normal file
72
internal/localdb/local_migrations_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,20 @@
|
|||||||
package localdb
|
package localdb
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||||
|
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||||
"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 +58,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 +69,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,19 +144,23 @@ func (l *LocalDB) GetDSN() (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add aggressive timeouts for offline-first architecture
|
cfg := mysqlDriver.NewConfig()
|
||||||
// timeout: connection establishment timeout (3s)
|
cfg.User = settings.User
|
||||||
// readTimeout: I/O read timeout (3s)
|
cfg.Passwd = settings.PasswordEncrypted // Contains decrypted password after GetSettings
|
||||||
// writeTimeout: I/O write timeout (3s)
|
cfg.Net = "tcp"
|
||||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=3s&readTimeout=3s&writeTimeout=3s",
|
cfg.Addr = net.JoinHostPort(settings.Host, strconv.Itoa(settings.Port))
|
||||||
settings.User,
|
cfg.DBName = settings.Database
|
||||||
settings.PasswordEncrypted, // Contains decrypted password after GetSettings
|
cfg.ParseTime = true
|
||||||
settings.Host,
|
cfg.Loc = time.Local
|
||||||
settings.Port,
|
// Add aggressive timeouts for offline-first architecture.
|
||||||
settings.Database,
|
cfg.Timeout = 3 * time.Second
|
||||||
)
|
cfg.ReadTimeout = 3 * time.Second
|
||||||
|
cfg.WriteTimeout = 3 * time.Second
|
||||||
|
cfg.Params = map[string]string{
|
||||||
|
"charset": "utf8mb4",
|
||||||
|
}
|
||||||
|
|
||||||
return dsn, nil
|
return cfg.FormatDSN(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DB returns the underlying gorm.DB for advanced operations
|
// DB returns the underlying gorm.DB for advanced operations
|
||||||
@@ -172,6 +188,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
|
||||||
@@ -193,7 +419,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: ¬e,
|
||||||
|
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
|
||||||
@@ -258,6 +537,15 @@ func (l *LocalDB) GetLocalPricelistByServerID(serverID uint) (*LocalPricelist, e
|
|||||||
return &pricelist, nil
|
return &pricelist, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetLocalPricelistByVersion returns a local pricelist by version string.
|
||||||
|
func (l *LocalDB) GetLocalPricelistByVersion(version string) (*LocalPricelist, error) {
|
||||||
|
var pricelist LocalPricelist
|
||||||
|
if err := l.db.Where("version = ?", version).First(&pricelist).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &pricelist, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetLocalPricelistByID returns a local pricelist by its local ID
|
// GetLocalPricelistByID returns a local pricelist by its local ID
|
||||||
func (l *LocalDB) GetLocalPricelistByID(id uint) (*LocalPricelist, error) {
|
func (l *LocalDB) GetLocalPricelistByID(id uint) (*LocalPricelist, error) {
|
||||||
var pricelist LocalPricelist
|
var pricelist LocalPricelist
|
||||||
@@ -333,6 +621,25 @@ func (l *LocalDB) MarkPricelistAsUsed(pricelistID uint, isUsed bool) error {
|
|||||||
Update("is_used", isUsed).Error
|
Update("is_used", isUsed).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RecalculateAllLocalPricelistUsage refreshes local_pricelists.is_used based on active configurations.
|
||||||
|
func (l *LocalDB) RecalculateAllLocalPricelistUsage() error {
|
||||||
|
return l.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Model(&LocalPricelist{}).Where("1 = 1").Update("is_used", false).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Exec(`
|
||||||
|
UPDATE local_pricelists
|
||||||
|
SET is_used = 1
|
||||||
|
WHERE server_id IN (
|
||||||
|
SELECT DISTINCT pricelist_id
|
||||||
|
FROM local_configurations
|
||||||
|
WHERE pricelist_id IS NOT NULL AND is_active = 1
|
||||||
|
)
|
||||||
|
`).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteLocalPricelist deletes a pricelist and its items
|
// DeleteLocalPricelist deletes a pricelist and its items
|
||||||
func (l *LocalDB) DeleteLocalPricelist(id uint) error {
|
func (l *LocalDB) DeleteLocalPricelist(id uint) error {
|
||||||
// Delete items first
|
// Delete items first
|
||||||
@@ -415,6 +722,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()
|
||||||
|
|||||||
60
internal/localdb/migration_projects_test.go
Normal file
60
internal/localdb/migration_projects_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
131
internal/localdb/migration_versioning_test.go
Normal file
131
internal/localdb/migration_versioning_test.go
Normal 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(¤tVersionID).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
|
||||||
|
}
|
||||||
239
internal/localdb/migrations.go
Normal file
239
internal/localdb/migrations.go
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2026_02_06_pricelist_backfill",
|
||||||
|
name: "Attach existing configurations to latest local pricelist and recalc usage",
|
||||||
|
run: backfillConfigurationPricelists,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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: ¬e,
|
||||||
|
AppVersion: "backfill",
|
||||||
|
CreatedAt: chooseNonZeroTime(cfg.CreatedAt, time.Now()),
|
||||||
|
}
|
||||||
|
if err := tx.Create(&version).Error; err != nil {
|
||||||
|
return fmt.Errorf("create v1 backfill for %s: %w", cfg.UUID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.CurrentVersionID == nil || *cfg.CurrentVersionID == "" {
|
||||||
|
var latest LocalConfigurationVersion
|
||||||
|
if err := tx.Where("configuration_uuid = ?", cfg.UUID).
|
||||||
|
Order("version_no DESC").
|
||||||
|
First(&latest).Error; err != nil {
|
||||||
|
return fmt.Errorf("load latest version for %s: %w", cfg.UUID, err)
|
||||||
|
}
|
||||||
|
if err := tx.Model(&LocalConfiguration{}).
|
||||||
|
Where("uuid = ?", cfg.UUID).
|
||||||
|
Update("current_version_id", latest.ID).Error; err != nil {
|
||||||
|
return fmt.Errorf("set current version for %s: %w", cfg.UUID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func backfillConfigurationIsActive(tx *gorm.DB) error {
|
||||||
|
return tx.Exec("UPDATE local_configurations SET is_active = 1 WHERE is_active IS NULL").Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func backfillProjectsForConfigurations(tx *gorm.DB) error {
|
||||||
|
var owners []string
|
||||||
|
if err := tx.Model(&LocalConfiguration{}).
|
||||||
|
Distinct("original_username").
|
||||||
|
Pluck("original_username", &owners).Error; err != nil {
|
||||||
|
return fmt.Errorf("load owners for projects backfill: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, owner := range owners {
|
||||||
|
project, err := ensureDefaultProjectTx(tx, owner)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Model(&LocalConfiguration{}).
|
||||||
|
Where("original_username = ? AND (project_uuid IS NULL OR project_uuid = '')", owner).
|
||||||
|
Update("project_uuid", project.UUID).Error; err != nil {
|
||||||
|
return fmt.Errorf("assign default project for owner %s: %w", owner, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureDefaultProjectTx(tx *gorm.DB, ownerUsername string) (*LocalProject, error) {
|
||||||
|
var project LocalProject
|
||||||
|
err := tx.Where("owner_username = ? AND is_system = ? AND name = ?", ownerUsername, true, "Без проекта").
|
||||||
|
First(&project).Error
|
||||||
|
if err == nil {
|
||||||
|
return &project, nil
|
||||||
|
}
|
||||||
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, fmt.Errorf("load system project for %s: %w", ownerUsername, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
project = LocalProject{
|
||||||
|
UUID: uuid.NewString(),
|
||||||
|
OwnerUsername: ownerUsername,
|
||||||
|
Name: "Без проекта",
|
||||||
|
IsActive: true,
|
||||||
|
IsSystem: true,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
SyncStatus: "pending",
|
||||||
|
}
|
||||||
|
if err := tx.Create(&project).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("create system project for %s: %w", ownerUsername, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &project, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func backfillConfigurationPricelists(tx *gorm.DB) error {
|
||||||
|
var latest LocalPricelist
|
||||||
|
if err := tx.Order("created_at DESC").First(&latest).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("load latest local pricelist: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Model(&LocalConfiguration{}).
|
||||||
|
Where("pricelist_id IS NULL").
|
||||||
|
Update("pricelist_id", latest.ServerID).Error; err != nil {
|
||||||
|
return fmt.Errorf("backfill configuration pricelist_id: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Model(&LocalPricelist{}).Where("1 = 1").Update("is_used", false).Error; err != nil {
|
||||||
|
return fmt.Errorf("reset local pricelist usage flags: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Exec(`
|
||||||
|
UPDATE local_pricelists
|
||||||
|
SET is_used = 1
|
||||||
|
WHERE server_id IN (
|
||||||
|
SELECT DISTINCT pricelist_id
|
||||||
|
FROM local_configurations
|
||||||
|
WHERE pricelist_id IS NOT NULL AND is_active = 1
|
||||||
|
)
|
||||||
|
`).Error; err != nil {
|
||||||
|
return fmt.Errorf("recalculate local pricelist usage flags: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func chooseNonZeroTime(candidate time.Time, fallback time.Time) time.Time {
|
||||||
|
if candidate.IsZero() {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
@@ -59,37 +59,79 @@ func (c LocalConfigItems) Total() float64 {
|
|||||||
|
|
||||||
// LocalConfiguration stores configurations in local SQLite
|
// LocalConfiguration stores configurations in local SQLite
|
||||||
type LocalConfiguration struct {
|
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
|
||||||
Name string `gorm:"not null" json:"name"`
|
ProjectUUID *string `gorm:"index" json:"project_uuid,omitempty"`
|
||||||
Items LocalConfigItems `gorm:"type:text" json:"items"` // JSON stored as text in SQLite
|
CurrentVersionID *string `gorm:"index" json:"current_version_id,omitempty"`
|
||||||
TotalPrice *float64 `json:"total_price"`
|
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
||||||
CustomPrice *float64 `json:"custom_price"`
|
Name string `gorm:"not null" json:"name"`
|
||||||
Notes string `json:"notes"`
|
Items LocalConfigItems `gorm:"type:text" json:"items"` // JSON stored as text in SQLite
|
||||||
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
TotalPrice *float64 `json:"total_price"`
|
||||||
ServerCount int `gorm:"default:1" json:"server_count"`
|
CustomPrice *float64 `json:"custom_price"`
|
||||||
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
Notes string `json:"notes"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
ServerCount int `gorm:"default:1" json:"server_count"`
|
||||||
SyncedAt *time.Time `json:"synced_at"`
|
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
|
||||||
SyncStatus string `gorm:"default:'local'" json:"sync_status"` // 'local', 'synced', 'modified'
|
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
||||||
OriginalUserID uint `json:"original_user_id"` // UserID from MariaDB for reference
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
SyncedAt *time.Time `json:"synced_at"`
|
||||||
|
SyncStatus string `gorm:"default:'local'" json:"sync_status"` // 'local', 'synced', 'modified'
|
||||||
|
OriginalUserID uint `json:"original_user_id"` // UserID from MariaDB for reference
|
||||||
|
OriginalUsername string `gorm:"not null;default:'';index" json:"original_username"`
|
||||||
|
CurrentVersion *LocalConfigurationVersion `gorm:"foreignKey:CurrentVersionID;references:ID" json:"current_version,omitempty"`
|
||||||
|
Versions []LocalConfigurationVersion `gorm:"foreignKey:ConfigurationUUID;references:UUID" json:"versions,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (LocalConfiguration) TableName() string {
|
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"`
|
||||||
ServerID uint `gorm:"not null" json:"server_id"` // ID on MariaDB server
|
ServerID uint `gorm:"not null" json:"server_id"` // ID on MariaDB server
|
||||||
Version string `gorm:"uniqueIndex;not null" json:"version"`
|
Version string `gorm:"uniqueIndex;not null" json:"version"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
SyncedAt time.Time `json:"synced_at"`
|
SyncedAt time.Time `json:"synced_at"`
|
||||||
IsUsed bool `gorm:"default:false" json:"is_used"` // Used by any local configuration
|
IsUsed bool `gorm:"default:false" json:"is_used"` // Used by any local configuration
|
||||||
}
|
}
|
||||||
|
|
||||||
func (LocalPricelist) TableName() string {
|
func (LocalPricelist) TableName() string {
|
||||||
@@ -127,7 +169,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
|
||||||
|
|||||||
84
internal/localdb/snapshots.go
Normal file
84
internal/localdb/snapshots.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package localdb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BuildConfigurationSnapshot serializes the full local configuration state.
|
||||||
|
func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
|
||||||
|
snapshot := map[string]interface{}{
|
||||||
|
"id": localCfg.ID,
|
||||||
|
"uuid": localCfg.UUID,
|
||||||
|
"server_id": localCfg.ServerID,
|
||||||
|
"project_uuid": localCfg.ProjectUUID,
|
||||||
|
"current_version_id": localCfg.CurrentVersionID,
|
||||||
|
"is_active": localCfg.IsActive,
|
||||||
|
"name": localCfg.Name,
|
||||||
|
"items": localCfg.Items,
|
||||||
|
"total_price": localCfg.TotalPrice,
|
||||||
|
"custom_price": localCfg.CustomPrice,
|
||||||
|
"notes": localCfg.Notes,
|
||||||
|
"is_template": localCfg.IsTemplate,
|
||||||
|
"server_count": localCfg.ServerCount,
|
||||||
|
"pricelist_id": localCfg.PricelistID,
|
||||||
|
"price_updated_at": localCfg.PriceUpdatedAt,
|
||||||
|
"created_at": localCfg.CreatedAt,
|
||||||
|
"updated_at": localCfg.UpdatedAt,
|
||||||
|
"synced_at": localCfg.SyncedAt,
|
||||||
|
"sync_status": localCfg.SyncStatus,
|
||||||
|
"original_user_id": localCfg.OriginalUserID,
|
||||||
|
"original_username": localCfg.OriginalUsername,
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(snapshot)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("marshal configuration snapshot: %w", err)
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeConfigurationSnapshot returns editable fields from one saved snapshot.
|
||||||
|
func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
|
||||||
|
var snapshot struct {
|
||||||
|
ProjectUUID *string `json:"project_uuid"`
|
||||||
|
IsActive *bool `json:"is_active"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Items LocalConfigItems `json:"items"`
|
||||||
|
TotalPrice *float64 `json:"total_price"`
|
||||||
|
CustomPrice *float64 `json:"custom_price"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
IsTemplate bool `json:"is_template"`
|
||||||
|
ServerCount int `json:"server_count"`
|
||||||
|
PricelistID *uint `json:"pricelist_id"`
|
||||||
|
PriceUpdatedAt *time.Time `json:"price_updated_at"`
|
||||||
|
OriginalUserID uint `json:"original_user_id"`
|
||||||
|
OriginalUsername string `json:"original_username"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal([]byte(data), &snapshot); err != nil {
|
||||||
|
return nil, fmt.Errorf("unmarshal snapshot JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
isActive := true
|
||||||
|
if snapshot.IsActive != nil {
|
||||||
|
isActive = *snapshot.IsActive
|
||||||
|
}
|
||||||
|
|
||||||
|
return &LocalConfiguration{
|
||||||
|
IsActive: isActive,
|
||||||
|
ProjectUUID: snapshot.ProjectUUID,
|
||||||
|
Name: snapshot.Name,
|
||||||
|
Items: snapshot.Items,
|
||||||
|
TotalPrice: snapshot.TotalPrice,
|
||||||
|
CustomPrice: snapshot.CustomPrice,
|
||||||
|
Notes: snapshot.Notes,
|
||||||
|
IsTemplate: snapshot.IsTemplate,
|
||||||
|
ServerCount: snapshot.ServerCount,
|
||||||
|
PricelistID: snapshot.PricelistID,
|
||||||
|
PriceUpdatedAt: snapshot.PriceUpdatedAt,
|
||||||
|
OriginalUserID: snapshot.OriginalUserID,
|
||||||
|
OriginalUsername: snapshot.OriginalUsername,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,18 +40,22 @@ 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
|
||||||
Name string `gorm:"size:200;not null" json:"name"`
|
OwnerUsername string `gorm:"size:100;not null;default:'';index" json:"owner_username"`
|
||||||
Items ConfigItems `gorm:"type:json;not null" json:"items"`
|
ProjectUUID *string `gorm:"size:36;index" json:"project_uuid,omitempty"`
|
||||||
TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"`
|
AppVersion string `gorm:"size:64" json:"app_version,omitempty"`
|
||||||
CustomPrice *float64 `gorm:"type:decimal(12,2)" json:"custom_price"`
|
Name string `gorm:"size:200;not null" json:"name"`
|
||||||
Notes string `gorm:"type:text" json:"notes"`
|
Items ConfigItems `gorm:"type:json;not null" json:"items"`
|
||||||
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"`
|
||||||
ServerCount int `gorm:"default:1" json:"server_count"`
|
CustomPrice *float64 `gorm:"type:decimal(12,2)" json:"custom_price"`
|
||||||
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
Notes string `gorm:"type:text" json:"notes"`
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
||||||
|
ServerCount int `gorm:"default:1" json:"server_count"`
|
||||||
|
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
|
||||||
|
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
||||||
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
|
|
||||||
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ func AllModels() []interface{} {
|
|||||||
&User{},
|
&User{},
|
||||||
&Category{},
|
&Category{},
|
||||||
&LotMetadata{},
|
&LotMetadata{},
|
||||||
|
&Project{},
|
||||||
&Configuration{},
|
&Configuration{},
|
||||||
&PriceOverride{},
|
&PriceOverride{},
|
||||||
&PricingAlert{},
|
&PricingAlert{},
|
||||||
|
|||||||
18
internal/models/project.go
Normal file
18
internal/models/project.go
Normal 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"
|
||||||
|
}
|
||||||
227
internal/models/sql_migrations.go
Normal file
227
internal/models/sql_migrations.go
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SQLSchemaMigration struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||||
|
Filename string `gorm:"size:255;uniqueIndex;not null"`
|
||||||
|
AppliedAt time.Time `gorm:"autoCreateTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (SQLSchemaMigration) TableName() string {
|
||||||
|
return "qt_schema_migrations"
|
||||||
|
}
|
||||||
|
|
||||||
|
// NeedsSQLMigrations reports whether at least one SQL migration from migrationsDir
|
||||||
|
// is not yet recorded in qt_schema_migrations.
|
||||||
|
func NeedsSQLMigrations(db *gorm.DB, migrationsDir string) (bool, error) {
|
||||||
|
files, err := listSQLMigrationFiles(migrationsDir)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if len(files) == 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If tracking table does not exist yet, migrations are required.
|
||||||
|
if !db.Migrator().HasTable(&SQLSchemaMigration{}) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
if err := db.Model(&SQLSchemaMigration{}).Where("filename IN ?", files).Count(&count).Error; err != nil {
|
||||||
|
return false, fmt.Errorf("check applied migrations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return count < int64(len(files)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunSQLMigrations applies SQL files from migrationsDir once and records them in qt_schema_migrations.
|
||||||
|
// Local SQLite-only scripts are skipped automatically.
|
||||||
|
func RunSQLMigrations(db *gorm.DB, migrationsDir string) error {
|
||||||
|
if err := ensureSQLMigrationsTable(db); err != nil {
|
||||||
|
return fmt.Errorf("migrate qt_schema_migrations table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := listSQLMigrationFiles(migrationsDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, filename := range files {
|
||||||
|
var count int64
|
||||||
|
if err := db.Model(&SQLSchemaMigration{}).Where("filename = ?", filename).Count(&count).Error; err != nil {
|
||||||
|
return fmt.Errorf("check migration %s: %w", filename, err)
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(migrationsDir, filename)
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read migration %s: %w", filename, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
statements := splitSQLStatements(string(content))
|
||||||
|
if len(statements) == 0 {
|
||||||
|
if err := db.Create(&SQLSchemaMigration{Filename: filename}).Error; err != nil {
|
||||||
|
return fmt.Errorf("record empty migration %s: %w", filename, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := executeMigrationStatements(db, filename, statements); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := db.Create(&SQLSchemaMigration{Filename: filename}).Error; err != nil {
|
||||||
|
return fmt.Errorf("record migration %s: %w", filename, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsMigrationPermissionError returns true if err indicates insufficient privileges
|
||||||
|
// to create/alter/read migration metadata or target schema objects.
|
||||||
|
func IsMigrationPermissionError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var mysqlErr *mysqlDriver.MySQLError
|
||||||
|
if errors.As(err, &mysqlErr) {
|
||||||
|
switch mysqlErr.Number {
|
||||||
|
case 1044, 1045, 1142, 1143, 1227:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lower := strings.ToLower(err.Error())
|
||||||
|
patterns := []string{
|
||||||
|
"command denied to user",
|
||||||
|
"access denied for user",
|
||||||
|
"permission denied",
|
||||||
|
"insufficient privilege",
|
||||||
|
"sqlstate 42000",
|
||||||
|
}
|
||||||
|
for _, pattern := range patterns {
|
||||||
|
if strings.Contains(lower, pattern) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureSQLMigrationsTable(db *gorm.DB) error {
|
||||||
|
stmt := `
|
||||||
|
CREATE TABLE IF NOT EXISTS qt_schema_migrations (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
filename VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);`
|
||||||
|
return db.Exec(stmt).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeMigrationStatements(db *gorm.DB, filename string, statements []string) error {
|
||||||
|
for _, stmt := range statements {
|
||||||
|
if err := db.Exec(stmt).Error; err != nil {
|
||||||
|
if isIgnorableMigrationError(err.Error()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return fmt.Errorf("exec migration %s statement %q: %w", filename, stmt, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSQLiteOnlyMigration(filename string) bool {
|
||||||
|
lower := strings.ToLower(filename)
|
||||||
|
return strings.Contains(lower, "local_")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isIgnorableMigrationError(message string) bool {
|
||||||
|
lower := strings.ToLower(message)
|
||||||
|
ignorable := []string{
|
||||||
|
"duplicate column name",
|
||||||
|
"duplicate key name",
|
||||||
|
"already exists",
|
||||||
|
"can't create table",
|
||||||
|
"duplicate foreign key constraint name",
|
||||||
|
"errno 121",
|
||||||
|
}
|
||||||
|
for _, pattern := range ignorable {
|
||||||
|
if strings.Contains(lower, pattern) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitSQLStatements(script string) []string {
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(script))
|
||||||
|
scanner.Buffer(make([]byte, 1024), 1024*1024)
|
||||||
|
|
||||||
|
lines := make([]string, 0, 128)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "--") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, scanner.Text())
|
||||||
|
}
|
||||||
|
|
||||||
|
combined := strings.Join(lines, "\n")
|
||||||
|
raw := strings.Split(combined, ";")
|
||||||
|
stmts := make([]string, 0, len(raw))
|
||||||
|
for _, stmt := range raw {
|
||||||
|
trimmed := strings.TrimSpace(stmt)
|
||||||
|
if trimmed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stmts = append(stmts, trimmed)
|
||||||
|
}
|
||||||
|
return stmts
|
||||||
|
}
|
||||||
|
|
||||||
|
func listSQLMigrationFiles(migrationsDir string) ([]string, error) {
|
||||||
|
entries, err := os.ReadDir(migrationsDir)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read migrations dir %s: %w", migrationsDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files := make([]string, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := entry.Name()
|
||||||
|
if !strings.HasSuffix(strings.ToLower(name), ".sql") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isSQLiteOnlyMigration(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
files = append(files, name)
|
||||||
|
}
|
||||||
|
sort.Strings(files)
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
@@ -110,6 +110,10 @@ func (r *ComponentRepository) Update(component *models.LotMetadata) error {
|
|||||||
return r.db.Save(component).Error
|
return r.db.Save(component).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *ComponentRepository) DB() *gorm.DB {
|
||||||
|
return r.db
|
||||||
|
}
|
||||||
|
|
||||||
func (r *ComponentRepository) Create(component *models.LotMetadata) error {
|
func (r *ComponentRepository) Create(component *models.LotMetadata) error {
|
||||||
return r.db.Create(component).Error
|
return r.db.Create(component).Error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package repository
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -29,11 +31,40 @@ 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 {
|
||||||
var itemCount int64
|
var itemCount int64
|
||||||
r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", pl.ID).Count(&itemCount)
|
r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", pl.ID).Count(&itemCount)
|
||||||
|
usageCount, _ := r.CountUsage(pl.ID)
|
||||||
|
|
||||||
summaries[i] = models.PricelistSummary{
|
summaries[i] = models.PricelistSummary{
|
||||||
ID: pl.ID,
|
ID: pl.ID,
|
||||||
@@ -42,13 +73,13 @@ func (r *PricelistRepository) List(offset, limit int) ([]models.PricelistSummary
|
|||||||
CreatedAt: pl.CreatedAt,
|
CreatedAt: pl.CreatedAt,
|
||||||
CreatedBy: pl.CreatedBy,
|
CreatedBy: pl.CreatedBy,
|
||||||
IsActive: pl.IsActive,
|
IsActive: pl.IsActive,
|
||||||
UsageCount: pl.UsageCount,
|
UsageCount: int(usageCount),
|
||||||
ExpiresAt: pl.ExpiresAt,
|
ExpiresAt: pl.ExpiresAt,
|
||||||
ItemCount: itemCount,
|
ItemCount: itemCount,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return summaries, total, nil
|
return summaries
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetByID returns a pricelist by ID
|
// GetByID returns a pricelist by ID
|
||||||
@@ -62,6 +93,9 @@ func (r *PricelistRepository) GetByID(id uint) (*models.Pricelist, error) {
|
|||||||
var itemCount int64
|
var itemCount int64
|
||||||
r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", id).Count(&itemCount)
|
r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", id).Count(&itemCount)
|
||||||
pricelist.ItemCount = int(itemCount)
|
pricelist.ItemCount = int(itemCount)
|
||||||
|
if usageCount, err := r.CountUsage(id); err == nil {
|
||||||
|
pricelist.UsageCount = int(usageCount)
|
||||||
|
}
|
||||||
|
|
||||||
return &pricelist, nil
|
return &pricelist, nil
|
||||||
}
|
}
|
||||||
@@ -102,13 +136,13 @@ func (r *PricelistRepository) Update(pricelist *models.Pricelist) error {
|
|||||||
|
|
||||||
// Delete deletes a pricelist if usage_count is 0
|
// Delete deletes a pricelist if usage_count is 0
|
||||||
func (r *PricelistRepository) Delete(id uint) error {
|
func (r *PricelistRepository) Delete(id uint) error {
|
||||||
pricelist, err := r.GetByID(id)
|
usageCount, err := r.CountUsage(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if pricelist.UsageCount > 0 {
|
if usageCount > 0 {
|
||||||
return fmt.Errorf("cannot delete pricelist with usage_count > 0 (current: %d)", pricelist.UsageCount)
|
return fmt.Errorf("cannot delete pricelist with usage_count > 0 (current: %d)", usageCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete items first
|
// Delete items first
|
||||||
@@ -178,18 +212,49 @@ func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, sear
|
|||||||
return items, total, nil
|
return items, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetPriceForLot returns item price for a lot within a pricelist.
|
||||||
|
func (r *PricelistRepository) GetPriceForLot(pricelistID uint, lotName string) (float64, error) {
|
||||||
|
var item models.PricelistItem
|
||||||
|
if err := r.db.Where("pricelist_id = ? AND lot_name = ?", pricelistID, lotName).First(&item).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return item.Price, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetActive toggles active flag on a pricelist.
|
||||||
|
func (r *PricelistRepository) SetActive(id uint, isActive bool) error {
|
||||||
|
return r.db.Model(&models.Pricelist{}).Where("id = ?", id).Update("is_active", isActive).Error
|
||||||
|
}
|
||||||
|
|
||||||
// GenerateVersion generates a new version string in format YYYY-MM-DD-NNN
|
// GenerateVersion generates a new version string in format YYYY-MM-DD-NNN
|
||||||
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
|
||||||
@@ -248,6 +313,15 @@ func (r *PricelistRepository) DecrementUsageCount(id uint) error {
|
|||||||
UpdateColumn("usage_count", gorm.Expr("GREATEST(usage_count - 1, 0)")).Error
|
UpdateColumn("usage_count", gorm.Expr("GREATEST(usage_count - 1, 0)")).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountUsage returns number of configurations referencing pricelist.
|
||||||
|
func (r *PricelistRepository) CountUsage(id uint) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
if err := r.db.Table("qt_configurations").Where("pricelist_id = ?", id).Count(&count).Error; err != nil {
|
||||||
|
return 0, fmt.Errorf("counting configurations for pricelist %d: %w", id, err)
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetExpiredUnused returns pricelists that are expired and unused
|
// GetExpiredUnused returns pricelists that are expired and unused
|
||||||
func (r *PricelistRepository) GetExpiredUnused() ([]models.Pricelist, error) {
|
func (r *PricelistRepository) GetExpiredUnused() ([]models.Pricelist, error) {
|
||||||
var pricelists []models.Pricelist
|
var pricelists []models.Pricelist
|
||||||
|
|||||||
64
internal/repository/pricelist_test.go
Normal file
64
internal/repository/pricelist_test.go
Normal 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)
|
||||||
|
}
|
||||||
169
internal/repository/project.go
Normal file
169
internal/repository/project.go
Normal 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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -159,16 +159,17 @@ func (r *UnifiedRepo) SaveConfiguration(cfg *models.Configuration) error {
|
|||||||
|
|
||||||
// Offline: save to local SQLite and queue for sync
|
// Offline: save to local SQLite and queue for sync
|
||||||
localCfg := &localdb.LocalConfiguration{
|
localCfg := &localdb.LocalConfiguration{
|
||||||
UUID: cfg.UUID,
|
UUID: cfg.UUID,
|
||||||
Name: cfg.Name,
|
Name: cfg.Name,
|
||||||
TotalPrice: cfg.TotalPrice,
|
TotalPrice: cfg.TotalPrice,
|
||||||
CustomPrice: cfg.CustomPrice,
|
CustomPrice: cfg.CustomPrice,
|
||||||
Notes: cfg.Notes,
|
Notes: cfg.Notes,
|
||||||
IsTemplate: cfg.IsTemplate,
|
IsTemplate: cfg.IsTemplate,
|
||||||
ServerCount: cfg.ServerCount,
|
ServerCount: cfg.ServerCount,
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,15 +223,16 @@ func (r *UnifiedRepo) GetConfigurations(userID uint) ([]models.Configuration, er
|
|||||||
}
|
}
|
||||||
|
|
||||||
result[i] = models.Configuration{
|
result[i] = models.Configuration{
|
||||||
UUID: lc.UUID,
|
UUID: lc.UUID,
|
||||||
Name: lc.Name,
|
OwnerUsername: lc.OriginalUsername,
|
||||||
Items: items,
|
Name: lc.Name,
|
||||||
TotalPrice: lc.TotalPrice,
|
Items: items,
|
||||||
CustomPrice: lc.CustomPrice,
|
TotalPrice: lc.TotalPrice,
|
||||||
Notes: lc.Notes,
|
CustomPrice: lc.CustomPrice,
|
||||||
IsTemplate: lc.IsTemplate,
|
Notes: lc.Notes,
|
||||||
ServerCount: lc.ServerCount,
|
IsTemplate: lc.IsTemplate,
|
||||||
CreatedAt: lc.CreatedAt,
|
ServerCount: lc.ServerCount,
|
||||||
|
CreatedAt: lc.CreatedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,50 +4,67 @@ 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 (
|
||||||
ErrConfigNotFound = errors.New("configuration not found")
|
ErrConfigNotFound = errors.New("configuration not found")
|
||||||
ErrConfigForbidden = errors.New("access to configuration forbidden")
|
ErrConfigForbidden = errors.New("access to configuration forbidden")
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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
|
||||||
|
pricelistRepo *repository.PricelistRepository
|
||||||
quoteService *QuoteService
|
quoteService *QuoteService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewConfigurationService(
|
func NewConfigurationService(
|
||||||
configRepo *repository.ConfigurationRepository,
|
configRepo *repository.ConfigurationRepository,
|
||||||
|
projectRepo *repository.ProjectRepository,
|
||||||
componentRepo *repository.ComponentRepository,
|
componentRepo *repository.ComponentRepository,
|
||||||
|
pricelistRepo *repository.PricelistRepository,
|
||||||
quoteService *QuoteService,
|
quoteService *QuoteService,
|
||||||
) *ConfigurationService {
|
) *ConfigurationService {
|
||||||
return &ConfigurationService{
|
return &ConfigurationService{
|
||||||
configRepo: configRepo,
|
configRepo: configRepo,
|
||||||
|
projectRepo: projectRepo,
|
||||||
componentRepo: componentRepo,
|
componentRepo: componentRepo,
|
||||||
|
pricelistRepo: pricelistRepo,
|
||||||
quoteService: quoteService,
|
quoteService: quoteService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateConfigRequest struct {
|
type CreateConfigRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Items models.ConfigItems `json:"items"`
|
Items models.ConfigItems `json:"items"`
|
||||||
CustomPrice *float64 `json:"custom_price"`
|
ProjectUUID *string `json:"project_uuid,omitempty"`
|
||||||
Notes string `json:"notes"`
|
CustomPrice *float64 `json:"custom_price"`
|
||||||
IsTemplate bool `json:"is_template"`
|
Notes string `json:"notes"`
|
||||||
ServerCount int `json:"server_count"`
|
IsTemplate bool `json:"is_template"`
|
||||||
|
ServerCount int `json:"server_count"`
|
||||||
|
PricelistID *uint `json:"pricelist_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ConfigurationService) Create(userID uint, req *CreateConfigRequest) (*models.Configuration, error) {
|
func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||||
|
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
total := req.Items.Total()
|
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
|
||||||
@@ -56,15 +73,17 @@ 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,
|
||||||
Name: req.Name,
|
ProjectUUID: projectUUID,
|
||||||
Items: req.Items,
|
Name: req.Name,
|
||||||
TotalPrice: &total,
|
Items: req.Items,
|
||||||
CustomPrice: req.CustomPrice,
|
TotalPrice: &total,
|
||||||
Notes: req.Notes,
|
CustomPrice: req.CustomPrice,
|
||||||
IsTemplate: req.IsTemplate,
|
Notes: req.Notes,
|
||||||
ServerCount: req.ServerCount,
|
IsTemplate: req.IsTemplate,
|
||||||
|
ServerCount: req.ServerCount,
|
||||||
|
PricelistID: pricelistID,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.configRepo.Create(config); err != nil {
|
if err := s.configRepo.Create(config); err != nil {
|
||||||
@@ -77,30 +96,39 @@ 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
|
||||||
|
}
|
||||||
|
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
||||||
|
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,12 +137,14 @@ 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
|
||||||
config.Notes = req.Notes
|
config.Notes = req.Notes
|
||||||
config.IsTemplate = req.IsTemplate
|
config.IsTemplate = req.IsTemplate
|
||||||
config.ServerCount = req.ServerCount
|
config.ServerCount = req.ServerCount
|
||||||
|
config.PricelistID = pricelistID
|
||||||
|
|
||||||
if err := s.configRepo.Update(config); err != nil {
|
if err := s.configRepo.Update(config); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -123,26 +153,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 +185,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()
|
||||||
@@ -170,15 +211,17 @@ 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,
|
||||||
Name: newName,
|
ProjectUUID: resolvedProjectUUID,
|
||||||
Items: original.Items,
|
Name: newName,
|
||||||
TotalPrice: &total,
|
Items: original.Items,
|
||||||
CustomPrice: original.CustomPrice,
|
TotalPrice: &total,
|
||||||
Notes: original.Notes,
|
CustomPrice: original.CustomPrice,
|
||||||
IsTemplate: false, // Clone is never a template
|
Notes: original.Notes,
|
||||||
ServerCount: original.ServerCount,
|
IsTemplate: false, // Clone is never a template
|
||||||
|
ServerCount: original.ServerCount,
|
||||||
|
PricelistID: original.PricelistID,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.configRepo.Create(clone); err != nil {
|
if err := s.configRepo.Create(clone); err != nil {
|
||||||
@@ -188,7 +231,7 @@ func (s *ConfigurationService) Clone(configUUID string, userID uint, newName str
|
|||||||
return clone, nil
|
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 +240,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,18 +272,29 @@ 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
|
||||||
|
}
|
||||||
|
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
||||||
|
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
|
||||||
config.Notes = req.Notes
|
config.Notes = req.Notes
|
||||||
config.IsTemplate = req.IsTemplate
|
config.IsTemplate = req.IsTemplate
|
||||||
config.ServerCount = req.ServerCount
|
config.ServerCount = req.ServerCount
|
||||||
|
config.PricelistID = pricelistID
|
||||||
|
|
||||||
if err := s.configRepo.Update(config); err != nil {
|
if err := s.configRepo.Update(config); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -274,11 +328,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 {
|
||||||
@@ -286,15 +351,17 @@ 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,
|
||||||
Name: newName,
|
ProjectUUID: resolvedProjectUUID,
|
||||||
Items: original.Items,
|
Name: newName,
|
||||||
TotalPrice: &total,
|
Items: original.Items,
|
||||||
CustomPrice: original.CustomPrice,
|
TotalPrice: &total,
|
||||||
Notes: original.Notes,
|
CustomPrice: original.CustomPrice,
|
||||||
IsTemplate: false,
|
Notes: original.Notes,
|
||||||
ServerCount: original.ServerCount,
|
IsTemplate: false,
|
||||||
|
ServerCount: original.ServerCount,
|
||||||
|
PricelistID: original.PricelistID,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.configRepo.Create(clone); err != nil {
|
if err := s.configRepo.Create(clone); err != nil {
|
||||||
@@ -304,6 +371,43 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ConfigurationService) resolvePricelistID(pricelistID *uint) (*uint, error) {
|
||||||
|
if s.pricelistRepo == nil {
|
||||||
|
return pricelistID, nil
|
||||||
|
}
|
||||||
|
if pricelistID != nil && *pricelistID > 0 {
|
||||||
|
if _, err := s.pricelistRepo.GetByID(*pricelistID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return pricelistID, nil
|
||||||
|
}
|
||||||
|
latest, err := s.pricelistRepo.GetLatestActive()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &latest.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
// RefreshPricesNoAuth refreshes prices without ownership check
|
// 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)
|
||||||
@@ -311,8 +415,30 @@ func (s *ConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configu
|
|||||||
return nil, ErrConfigNotFound
|
return nil, ErrConfigNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var latestPricelistID *uint
|
||||||
|
if s.pricelistRepo != nil {
|
||||||
|
if pl, err := s.pricelistRepo.GetLatestActive(); err == nil {
|
||||||
|
latestPricelistID = &pl.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
updatedItems := make(models.ConfigItems, len(config.Items))
|
updatedItems := make(models.ConfigItems, len(config.Items))
|
||||||
for i, item := range config.Items {
|
for i, item := range config.Items {
|
||||||
|
if latestPricelistID != nil {
|
||||||
|
if price, err := s.pricelistRepo.GetPriceForLot(*latestPricelistID, item.LotName); err == nil && price > 0 {
|
||||||
|
updatedItems[i] = models.ConfigItem{
|
||||||
|
LotName: item.LotName,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
UnitPrice: price,
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.componentRepo == nil {
|
||||||
|
updatedItems[i] = item
|
||||||
|
continue
|
||||||
|
}
|
||||||
metadata, err := s.componentRepo.GetByLotName(item.LotName)
|
metadata, err := s.componentRepo.GetByLotName(item.LotName)
|
||||||
if err != nil || metadata.CurrentPrice == nil {
|
if err != nil || metadata.CurrentPrice == nil {
|
||||||
updatedItems[i] = item
|
updatedItems[i] = item
|
||||||
@@ -333,6 +459,9 @@ func (s *ConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configu
|
|||||||
}
|
}
|
||||||
|
|
||||||
config.TotalPrice = &total
|
config.TotalPrice = &total
|
||||||
|
if latestPricelistID != nil {
|
||||||
|
config.PricelistID = latestPricelistID
|
||||||
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
config.PriceUpdatedAt = &now
|
config.PriceUpdatedAt = &now
|
||||||
|
|
||||||
@@ -356,20 +485,42 @@ func (s *ConfigurationService) ListTemplates(page, perPage int) ([]models.Config
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RefreshPrices updates all component prices in the configuration with current prices
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var latestPricelistID *uint
|
||||||
|
if s.pricelistRepo != nil {
|
||||||
|
if pl, err := s.pricelistRepo.GetLatestActive(); err == nil {
|
||||||
|
latestPricelistID = &pl.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update prices for all items
|
// Update prices for all items
|
||||||
updatedItems := make(models.ConfigItems, len(config.Items))
|
updatedItems := make(models.ConfigItems, len(config.Items))
|
||||||
for i, item := range config.Items {
|
for i, item := range config.Items {
|
||||||
|
if latestPricelistID != nil {
|
||||||
|
if price, err := s.pricelistRepo.GetPriceForLot(*latestPricelistID, item.LotName); err == nil && price > 0 {
|
||||||
|
updatedItems[i] = models.ConfigItem{
|
||||||
|
LotName: item.LotName,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
UnitPrice: price,
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get current component price
|
// Get current component price
|
||||||
|
if s.componentRepo == nil {
|
||||||
|
updatedItems[i] = item
|
||||||
|
continue
|
||||||
|
}
|
||||||
metadata, err := s.componentRepo.GetByLotName(item.LotName)
|
metadata, err := s.componentRepo.GetByLotName(item.LotName)
|
||||||
if err != nil || metadata.CurrentPrice == nil {
|
if err != nil || metadata.CurrentPrice == nil {
|
||||||
// Keep original item if component not found or no price available
|
// Keep original item if component not found or no price available
|
||||||
@@ -395,6 +546,9 @@ func (s *ConfigurationService) RefreshPrices(uuid string, userID uint) (*models.
|
|||||||
}
|
}
|
||||||
|
|
||||||
config.TotalPrice = &total
|
config.TotalPrice = &total
|
||||||
|
if latestPricelistID != nil {
|
||||||
|
config.PricelistID = latestPricelistID
|
||||||
|
}
|
||||||
|
|
||||||
// Set price update timestamp
|
// Set price update timestamp
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -407,6 +561,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
357
internal/services/local_configuration_versioning_test.go
Normal file
357
internal/services/local_configuration_versioning_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,51 +1,116 @@
|
|||||||
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"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
repo *repository.PricelistRepository
|
repo *repository.PricelistRepository
|
||||||
componentRepo *repository.ComponentRepository
|
componentRepo *repository.ComponentRepository
|
||||||
|
pricingSvc *pricing.Service
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo *repository.ComponentRepository) *Service {
|
type CreateProgress struct {
|
||||||
|
Current int
|
||||||
|
Total int
|
||||||
|
Status string
|
||||||
|
Message string
|
||||||
|
Updated int
|
||||||
|
Errors int
|
||||||
|
LotName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo *repository.ComponentRepository, pricingSvc *pricing.Service) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
repo: repo,
|
repo: repo,
|
||||||
componentRepo: componentRepo,
|
componentRepo: componentRepo,
|
||||||
|
pricingSvc: pricingSvc,
|
||||||
db: db,
|
db: db,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
||||||
|
return s.CreateFromCurrentPricesWithProgress(createdBy, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFromCurrentPricesWithProgress creates a pricelist and reports coarse-grained progress.
|
||||||
|
func (s *Service) CreateFromCurrentPricesWithProgress(createdBy string, onProgress func(CreateProgress)) (*models.Pricelist, error) {
|
||||||
if s.repo == nil || s.db == nil {
|
if s.repo == nil || s.db == nil {
|
||||||
return nil, fmt.Errorf("offline mode: cannot create pricelists")
|
return nil, fmt.Errorf("offline mode: cannot create pricelists")
|
||||||
}
|
}
|
||||||
|
|
||||||
version, err := s.repo.GenerateVersion()
|
report := func(p CreateProgress) {
|
||||||
if err != nil {
|
if onProgress != nil {
|
||||||
return nil, fmt.Errorf("generating version: %w", err)
|
onProgress(p)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
report(CreateProgress{Current: 0, Total: 100, Status: "starting", Message: "Подготовка"})
|
||||||
|
|
||||||
|
updated, errs := 0, 0
|
||||||
|
if s.pricingSvc != nil {
|
||||||
|
report(CreateProgress{Current: 1, Total: 100, Status: "recalculating", Message: "Обновление цен компонентов"})
|
||||||
|
updated, errs = s.pricingSvc.RecalculateAllPricesWithProgress(func(p pricing.RecalculateProgress) {
|
||||||
|
if p.Total <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
phaseCurrent := 1 + int(float64(p.Current)/float64(p.Total)*90.0)
|
||||||
|
if phaseCurrent > 91 {
|
||||||
|
phaseCurrent = 91
|
||||||
|
}
|
||||||
|
report(CreateProgress{
|
||||||
|
Current: phaseCurrent,
|
||||||
|
Total: 100,
|
||||||
|
Status: "recalculating",
|
||||||
|
Message: "Обновление цен компонентов",
|
||||||
|
Updated: p.Updated,
|
||||||
|
Errors: p.Errors,
|
||||||
|
LotName: p.LotName,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
report(CreateProgress{Current: 92, Total: 100, Status: "recalculated", Message: "Цены обновлены", Updated: updated, Errors: errs})
|
||||||
|
|
||||||
|
report(CreateProgress{Current: 95, Total: 100, Status: "snapshot", Message: "Создание снимка прайслиста"})
|
||||||
expiresAt := time.Now().AddDate(1, 0, 0) // +1 year
|
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()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("generating version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
pricelist := &models.Pricelist{
|
pricelist = &models.Pricelist{
|
||||||
Version: version,
|
Version: version,
|
||||||
CreatedBy: createdBy,
|
CreatedBy: createdBy,
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
ExpiresAt: &expiresAt,
|
ExpiresAt: &expiresAt,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.repo.Create(pricelist); err != nil {
|
if err := s.repo.Create(pricelist); err != nil {
|
||||||
return nil, fmt.Errorf("creating pricelist: %w", err)
|
if isVersionConflictError(err) && attempt < maxCreateAttempts {
|
||||||
|
slog.Warn("pricelist version conflict, retrying",
|
||||||
|
"attempt", attempt,
|
||||||
|
"version", version,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
time.Sleep(time.Duration(attempt*25) * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("creating pricelist: %w", err)
|
||||||
|
}
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all components with prices from qt_lot_metadata
|
// Get all components with prices from qt_lot_metadata
|
||||||
@@ -86,10 +151,19 @@ func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist,
|
|||||||
"items", len(items),
|
"items", len(items),
|
||||||
"created_by", createdBy,
|
"created_by", createdBy,
|
||||||
)
|
)
|
||||||
|
report(CreateProgress{Current: 100, Total: 100, Status: "completed", Message: "Прайслист создан", Updated: updated, Errors: errs})
|
||||||
|
|
||||||
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 no database connection (offline mode), return empty list
|
||||||
@@ -107,6 +181,21 @@ func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, err
|
|||||||
return s.repo.List(offset, perPage)
|
return s.repo.List(offset, perPage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListActive returns active pricelists with pagination.
|
||||||
|
func (s *Service) ListActive(page, perPage int) ([]models.PricelistSummary, int64, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return []models.PricelistSummary{}, 0, nil
|
||||||
|
}
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if perPage < 1 {
|
||||||
|
perPage = 20
|
||||||
|
}
|
||||||
|
offset := (page - 1) * perPage
|
||||||
|
return s.repo.ListActive(offset, perPage)
|
||||||
|
}
|
||||||
|
|
||||||
// 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 {
|
if s.repo == nil {
|
||||||
@@ -138,6 +227,22 @@ func (s *Service) Delete(id uint) error {
|
|||||||
return s.repo.Delete(id)
|
return s.repo.Delete(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetActive toggles active state for a pricelist.
|
||||||
|
func (s *Service) SetActive(id uint, isActive bool) error {
|
||||||
|
if s.repo == nil {
|
||||||
|
return fmt.Errorf("offline mode: cannot update pricelists")
|
||||||
|
}
|
||||||
|
return s.repo.SetActive(id, isActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPriceForLot returns price by pricelist/lot.
|
||||||
|
func (s *Service) GetPriceForLot(pricelistID uint, lotName string) (float64, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return 0, fmt.Errorf("offline mode: pricelist service not available")
|
||||||
|
}
|
||||||
|
return s.repo.GetPriceForLot(pricelistID, lotName)
|
||||||
|
}
|
||||||
|
|
||||||
// CanWrite returns true if the user can create pricelists
|
// CanWrite returns true if the user can create pricelists
|
||||||
func (s *Service) CanWrite() bool {
|
func (s *Service) CanWrite() bool {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
|
|||||||
@@ -1,17 +1,28 @@
|
|||||||
package pricing
|
package pricing
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||||
"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"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
componentRepo *repository.ComponentRepository
|
componentRepo *repository.ComponentRepository
|
||||||
priceRepo *repository.PriceRepository
|
priceRepo *repository.PriceRepository
|
||||||
config config.PricingConfig
|
config config.PricingConfig
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecalculateProgress struct {
|
||||||
|
Current int
|
||||||
|
Total int
|
||||||
|
LotName string
|
||||||
|
Updated int
|
||||||
|
Errors int
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(
|
func NewService(
|
||||||
@@ -19,10 +30,16 @@ func NewService(
|
|||||||
priceRepo *repository.PriceRepository,
|
priceRepo *repository.PriceRepository,
|
||||||
cfg config.PricingConfig,
|
cfg config.PricingConfig,
|
||||||
) *Service {
|
) *Service {
|
||||||
|
var db *gorm.DB
|
||||||
|
if componentRepo != nil {
|
||||||
|
db = componentRepo.DB()
|
||||||
|
}
|
||||||
|
|
||||||
return &Service{
|
return &Service{
|
||||||
componentRepo: componentRepo,
|
componentRepo: componentRepo,
|
||||||
priceRepo: priceRepo,
|
priceRepo: priceRepo,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
|
db: db,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +85,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:
|
||||||
@@ -149,17 +166,17 @@ func (s *Service) GetPriceStats(lotName string, periodDays int) (*PriceStats, er
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &PriceStats{
|
return &PriceStats{
|
||||||
QuoteCount: len(points),
|
QuoteCount: len(points),
|
||||||
MinPrice: CalculatePercentile(prices, 0),
|
MinPrice: CalculatePercentile(prices, 0),
|
||||||
MaxPrice: CalculatePercentile(prices, 100),
|
MaxPrice: CalculatePercentile(prices, 100),
|
||||||
MedianPrice: CalculateMedian(prices),
|
MedianPrice: CalculateMedian(prices),
|
||||||
AveragePrice: CalculateAverage(prices),
|
AveragePrice: CalculateAverage(prices),
|
||||||
StdDeviation: CalculateStdDev(prices),
|
StdDeviation: CalculateStdDev(prices),
|
||||||
LatestPrice: points[0].Price,
|
LatestPrice: points[0].Price,
|
||||||
LatestDate: points[0].Date,
|
LatestDate: points[0].Date,
|
||||||
OldestDate: points[len(points)-1].Date,
|
OldestDate: points[len(points)-1].Date,
|
||||||
Percentile25: CalculatePercentile(prices, 25),
|
Percentile25: CalculatePercentile(prices, 25),
|
||||||
Percentile75: CalculatePercentile(prices, 75),
|
Percentile75: CalculatePercentile(prices, 75),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,27 +196,183 @@ type PriceStats struct {
|
|||||||
|
|
||||||
// RecalculateAllPrices recalculates prices for all components
|
// RecalculateAllPrices recalculates prices for all components
|
||||||
func (s *Service) RecalculateAllPrices() (updated int, errors int) {
|
func (s *Service) RecalculateAllPrices() (updated int, errors int) {
|
||||||
// Get all components
|
return s.RecalculateAllPricesWithProgress(nil)
|
||||||
filter := repository.ComponentFilter{}
|
}
|
||||||
offset := 0
|
|
||||||
limit := 100
|
|
||||||
|
|
||||||
for {
|
// RecalculateAllPricesWithProgress recalculates prices and reports progress.
|
||||||
components, _, err := s.componentRepo.List(filter, offset, limit)
|
func (s *Service) RecalculateAllPricesWithProgress(onProgress func(RecalculateProgress)) (updated int, errors int) {
|
||||||
if err != nil || len(components) == 0 {
|
if s.db == nil {
|
||||||
break
|
return 0, 0
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, comp := range components {
|
// Logic mirrors "Обновить цены" in admin pricing.
|
||||||
if err := s.UpdateComponentPrice(comp.LotName); err != nil {
|
var components []models.LotMetadata
|
||||||
errors++
|
if err := s.db.Find(&components).Error; err != nil {
|
||||||
} else {
|
return 0, len(components)
|
||||||
updated++
|
}
|
||||||
|
total := len(components)
|
||||||
|
|
||||||
|
var allLotNames []string
|
||||||
|
_ = s.db.Model(&models.LotMetadata{}).Pluck("lot_name", &allLotNames).Error
|
||||||
|
|
||||||
|
type lotDate struct {
|
||||||
|
Lot string
|
||||||
|
Date time.Time
|
||||||
|
}
|
||||||
|
var latestDates []lotDate
|
||||||
|
_ = s.db.Raw(`SELECT lot, MAX(date) as date FROM lot_log GROUP BY lot`).Scan(&latestDates).Error
|
||||||
|
lotLatestDate := make(map[string]time.Time, len(latestDates))
|
||||||
|
for _, ld := range latestDates {
|
||||||
|
lotLatestDate[ld.Lot] = ld.Date
|
||||||
|
}
|
||||||
|
|
||||||
|
var skipped, manual, unchanged int
|
||||||
|
now := time.Now()
|
||||||
|
current := 0
|
||||||
|
|
||||||
|
for _, comp := range components {
|
||||||
|
current++
|
||||||
|
reportProgress := func() {
|
||||||
|
if onProgress != nil && (current%10 == 0 || current == total) {
|
||||||
|
onProgress(RecalculateProgress{
|
||||||
|
Current: current,
|
||||||
|
Total: total,
|
||||||
|
LotName: comp.LotName,
|
||||||
|
Updated: updated,
|
||||||
|
Errors: errors,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
offset += limit
|
if comp.ManualPrice != nil && *comp.ManualPrice > 0 {
|
||||||
|
manual++
|
||||||
|
reportProgress()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
method := comp.PriceMethod
|
||||||
|
if method == "" {
|
||||||
|
method = models.PriceMethodMedian
|
||||||
|
}
|
||||||
|
|
||||||
|
var sourceLots []string
|
||||||
|
if comp.MetaPrices != "" {
|
||||||
|
sourceLots = expandMetaPricesWithCache(comp.MetaPrices, comp.LotName, allLotNames)
|
||||||
|
} else {
|
||||||
|
sourceLots = []string{comp.LotName}
|
||||||
|
}
|
||||||
|
if len(sourceLots) == 0 {
|
||||||
|
skipped++
|
||||||
|
reportProgress()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if comp.PriceUpdatedAt != nil {
|
||||||
|
hasNewData := false
|
||||||
|
for _, lot := range sourceLots {
|
||||||
|
if latestDate, ok := lotLatestDate[lot]; ok && latestDate.After(*comp.PriceUpdatedAt) {
|
||||||
|
hasNewData = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasNewData {
|
||||||
|
unchanged++
|
||||||
|
reportProgress()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var prices []float64
|
||||||
|
if comp.PricePeriodDays > 0 {
|
||||||
|
_ = s.db.Raw(
|
||||||
|
`SELECT price FROM lot_log WHERE lot IN ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
|
||||||
|
sourceLots, comp.PricePeriodDays,
|
||||||
|
).Pluck("price", &prices).Error
|
||||||
|
} else {
|
||||||
|
_ = s.db.Raw(
|
||||||
|
`SELECT price FROM lot_log WHERE lot IN ? ORDER BY price`,
|
||||||
|
sourceLots,
|
||||||
|
).Pluck("price", &prices).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(prices) == 0 && comp.PricePeriodDays > 0 {
|
||||||
|
_ = s.db.Raw(`SELECT price FROM lot_log WHERE lot IN ? ORDER BY price`, sourceLots).Pluck("price", &prices).Error
|
||||||
|
}
|
||||||
|
if len(prices) == 0 {
|
||||||
|
skipped++
|
||||||
|
reportProgress()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var basePrice float64
|
||||||
|
switch method {
|
||||||
|
case models.PriceMethodAverage:
|
||||||
|
basePrice = CalculateAverage(prices)
|
||||||
|
default:
|
||||||
|
basePrice = CalculateMedian(prices)
|
||||||
|
}
|
||||||
|
if basePrice <= 0 {
|
||||||
|
skipped++
|
||||||
|
reportProgress()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
finalPrice := basePrice
|
||||||
|
if comp.PriceCoefficient != 0 {
|
||||||
|
finalPrice = finalPrice * (1 + comp.PriceCoefficient/100)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.Model(&models.LotMetadata{}).
|
||||||
|
Where("lot_name = ?", comp.LotName).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"current_price": finalPrice,
|
||||||
|
"price_updated_at": now,
|
||||||
|
}).Error; err != nil {
|
||||||
|
errors++
|
||||||
|
} else {
|
||||||
|
updated++
|
||||||
|
}
|
||||||
|
|
||||||
|
reportProgress()
|
||||||
|
}
|
||||||
|
|
||||||
|
if onProgress != nil && total == 0 {
|
||||||
|
onProgress(RecalculateProgress{
|
||||||
|
Current: 0,
|
||||||
|
Total: 0,
|
||||||
|
LotName: "",
|
||||||
|
Updated: updated,
|
||||||
|
Errors: errors,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return updated, errors
|
return updated, errors
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func expandMetaPricesWithCache(metaPrices, excludeLot string, allLotNames []string) []string {
|
||||||
|
sources := strings.Split(metaPrices, ",")
|
||||||
|
var result []string
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
|
||||||
|
for _, source := range sources {
|
||||||
|
source = strings.TrimSpace(source)
|
||||||
|
if source == "" || source == excludeLot {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(source, "*") {
|
||||||
|
prefix := strings.TrimSuffix(source, "*")
|
||||||
|
for _, lot := range allLotNames {
|
||||||
|
if strings.HasPrefix(lot, prefix) && lot != excludeLot && !seen[lot] {
|
||||||
|
result = append(result, lot)
|
||||||
|
seen[lot] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if !seen[source] {
|
||||||
|
result = append(result, source)
|
||||||
|
seen[source] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||
296
internal/services/project.go
Normal file
296
internal/services/project.go
Normal 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
|
||||||
|
}
|
||||||
@@ -9,14 +9,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrEmptyQuote = errors.New("quote cannot be empty")
|
ErrEmptyQuote = errors.New("quote cannot be empty")
|
||||||
ErrComponentNotFound = errors.New("component not found")
|
ErrComponentNotFound = errors.New("component not found")
|
||||||
ErrNoPriceAvailable = errors.New("no price available for component")
|
ErrNoPriceAvailable = errors.New("no price available for component")
|
||||||
)
|
)
|
||||||
|
|
||||||
type QuoteService struct {
|
type QuoteService struct {
|
||||||
componentRepo *repository.ComponentRepository
|
componentRepo *repository.ComponentRepository
|
||||||
statsRepo *repository.StatsRepository
|
statsRepo *repository.StatsRepository
|
||||||
pricingService *pricing.Service
|
pricingService *pricing.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,11 +43,11 @@ type QuoteItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type QuoteValidationResult struct {
|
type QuoteValidationResult struct {
|
||||||
Valid bool `json:"valid"`
|
Valid bool `json:"valid"`
|
||||||
Items []QuoteItem `json:"items"`
|
Items []QuoteItem `json:"items"`
|
||||||
Errors []string `json:"errors"`
|
Errors []string `json:"errors"`
|
||||||
Warnings []string `json:"warnings"`
|
Warnings []string `json:"warnings"`
|
||||||
Total float64 `json:"total"`
|
Total float64 `json:"total"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type QuoteRequest struct {
|
type QuoteRequest struct {
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -2,20 +2,28 @@ package sync
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/db"
|
"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 {
|
||||||
connMgr *db.ConnectionManager
|
connMgr *db.ConnectionManager
|
||||||
localDB *localdb.LocalDB
|
localDB *localdb.LocalDB
|
||||||
|
directDB *gorm.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a new sync service
|
// NewService creates a new sync service
|
||||||
@@ -26,6 +34,14 @@ func NewService(connMgr *db.ConnectionManager, localDB *localdb.LocalDB) *Servic
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
@@ -34,19 +50,122 @@ type SyncStatus struct {
|
|||||||
NeedsSync bool `json:"needs_sync"`
|
NeedsSync bool `json:"needs_sync"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UserSyncStatus struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
LastSyncAt time.Time `json:"last_sync_at"`
|
||||||
|
AppVersion string `json:"app_version,omitempty"`
|
||||||
|
IsOnline bool `json:"is_online"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
PricelistID *uint `json:"pricelist_id,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 (only if already connected, don't reconnect)
|
// Count server pricelists (only if already connected, don't reconnect)
|
||||||
serverCount := 0
|
serverCount := 0
|
||||||
connStatus := s.connMgr.GetStatus()
|
connStatus := s.getConnectionStatus()
|
||||||
if connStatus.IsConnected {
|
if connStatus.IsConnected {
|
||||||
if mariaDB, err := s.connMgr.GetDB(); err == nil && mariaDB != nil {
|
if mariaDB, err := s.getDB(); err == nil && mariaDB != nil {
|
||||||
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
||||||
serverPricelists, _, err := pricelistRepo.List(0, 1)
|
activeCount, err := pricelistRepo.CountActive()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
serverCount = len(serverPricelists)
|
serverCount = int(activeCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,13 +199,13 @@ func (s *Service) NeedSync() (bool, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if there are new pricelists on server (only if already connected)
|
// Check if there are new pricelists on server (only if already connected)
|
||||||
connStatus := s.connMgr.GetStatus()
|
connStatus := s.getConnectionStatus()
|
||||||
if !connStatus.IsConnected {
|
if !connStatus.IsConnected {
|
||||||
// If offline, can't check server, no need to sync
|
// If offline, can't check server, no need to sync
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
mariaDB, err := s.connMgr.GetDB()
|
mariaDB, err := s.getDB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If offline, can't check server, no need to sync
|
// If offline, can't check server, no need to sync
|
||||||
return false, nil
|
return false, nil
|
||||||
@@ -118,7 +237,7 @@ func (s *Service) SyncPricelists() (int, error) {
|
|||||||
slog.Info("starting pricelist sync")
|
slog.Info("starting pricelist sync")
|
||||||
|
|
||||||
// Get database connection
|
// Get database connection
|
||||||
mariaDB, err := s.connMgr.GetDB()
|
mariaDB, err := s.getDB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("database not available: %w", err)
|
return 0, fmt.Errorf("database not available: %w", err)
|
||||||
}
|
}
|
||||||
@@ -126,20 +245,24 @@ func (s *Service) SyncPricelists() (int, error) {
|
|||||||
// Create repository
|
// Create repository
|
||||||
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
||||||
|
|
||||||
// Get all active pricelists from server (up to 100)
|
// Get active pricelists from server (up to 100)
|
||||||
serverPricelists, _, err := pricelistRepo.List(0, 100)
|
serverPricelists, _, err := pricelistRepo.ListActive(0, 100)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("getting server pricelists: %w", err)
|
return 0, fmt.Errorf("getting active server pricelists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
synced := 0
|
synced := 0
|
||||||
var latestLocalID uint
|
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, track latest
|
// Already synced, track latest by server ID
|
||||||
latestLocalID = existing.ID
|
if pl.ID > latestServerID {
|
||||||
|
latestServerID = pl.ID
|
||||||
|
latestLocalID = existing.ID
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +290,10 @@ func (s *Service) SyncPricelists() (int, error) {
|
|||||||
slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount)
|
slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
latestLocalID = localPL.ID
|
if pl.ID > latestServerID {
|
||||||
|
latestServerID = pl.ID
|
||||||
|
latestLocalID = localPL.ID
|
||||||
|
}
|
||||||
synced++
|
synced++
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,11 +309,104 @@ func (s *Service) SyncPricelists() (int, error) {
|
|||||||
|
|
||||||
// Update last sync time
|
// Update last sync time
|
||||||
s.localDB.SetLastSyncTime(time.Now())
|
s.localDB.SetLastSyncTime(time.Now())
|
||||||
|
s.RecordSyncHeartbeat()
|
||||||
|
|
||||||
slog.Info("pricelist sync completed", "synced", synced, "total", len(serverPricelists))
|
slog.Info("pricelist sync completed", "synced", synced, "total", len(serverPricelists))
|
||||||
return synced, nil
|
return synced, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RecordSyncHeartbeat updates shared sync heartbeat for current DB user.
|
||||||
|
// Only users with write rights are expected to be able to update this table.
|
||||||
|
func (s *Service) RecordSyncHeartbeat() {
|
||||||
|
username := strings.TrimSpace(s.localDB.GetDBUser())
|
||||||
|
if username == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mariaDB, err := s.getDB()
|
||||||
|
if err != nil || mariaDB == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ensureUserSyncStatusTable(mariaDB); err != nil {
|
||||||
|
slog.Warn("sync heartbeat: failed to ensure table", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if err := mariaDB.Exec(`
|
||||||
|
INSERT INTO qt_pricelist_sync_status (username, last_sync_at, updated_at, app_version)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
last_sync_at = VALUES(last_sync_at),
|
||||||
|
updated_at = VALUES(updated_at),
|
||||||
|
app_version = VALUES(app_version)
|
||||||
|
`, username, now, now, appmeta.Version()).Error; err != nil {
|
||||||
|
slog.Debug("sync heartbeat: skipped", "username", username, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUserSyncStatuses returns users who have recorded sync heartbeat.
|
||||||
|
func (s *Service) ListUserSyncStatuses(onlineThreshold time.Duration) ([]UserSyncStatus, error) {
|
||||||
|
mariaDB, err := s.getDB()
|
||||||
|
if err != nil || mariaDB == nil {
|
||||||
|
return nil, ErrOffline
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ensureUserSyncStatusTable(mariaDB); err != nil {
|
||||||
|
return nil, fmt.Errorf("ensure sync status table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type row struct {
|
||||||
|
Username string `gorm:"column:username"`
|
||||||
|
LastSyncAt time.Time `gorm:"column:last_sync_at"`
|
||||||
|
AppVersion string `gorm:"column:app_version"`
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
if err := mariaDB.Raw(`
|
||||||
|
SELECT username, last_sync_at, COALESCE(app_version, '') AS app_version
|
||||||
|
FROM qt_pricelist_sync_status
|
||||||
|
ORDER BY last_sync_at DESC, username ASC
|
||||||
|
`).Scan(&rows).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("load sync status rows: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
result := make([]UserSyncStatus, 0, len(rows))
|
||||||
|
for i := range rows {
|
||||||
|
r := rows[i]
|
||||||
|
result = append(result, UserSyncStatus{
|
||||||
|
Username: r.Username,
|
||||||
|
LastSyncAt: r.LastSyncAt,
|
||||||
|
AppVersion: strings.TrimSpace(r.AppVersion),
|
||||||
|
IsOnline: now.Sub(r.LastSyncAt) <= onlineThreshold,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureUserSyncStatusTable(db *gorm.DB) error {
|
||||||
|
if err := db.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS qt_pricelist_sync_status (
|
||||||
|
username VARCHAR(100) NOT NULL,
|
||||||
|
last_sync_at DATETIME NOT NULL,
|
||||||
|
updated_at DATETIME NOT NULL,
|
||||||
|
app_version VARCHAR(64) NULL,
|
||||||
|
PRIMARY KEY (username),
|
||||||
|
INDEX idx_qt_pricelist_sync_status_last_sync (last_sync_at)
|
||||||
|
)
|
||||||
|
`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backward compatibility for environments where table was created without app_version.
|
||||||
|
return db.Exec(`
|
||||||
|
ALTER TABLE qt_pricelist_sync_status
|
||||||
|
ADD COLUMN IF NOT EXISTS app_version VARCHAR(64) NULL
|
||||||
|
`).Error
|
||||||
|
}
|
||||||
|
|
||||||
// SyncPricelistItems synchronizes items for a specific pricelist
|
// SyncPricelistItems synchronizes items for a specific pricelist
|
||||||
func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
|
func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
|
||||||
// Get local pricelist
|
// Get local pricelist
|
||||||
@@ -204,7 +423,7 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get database connection
|
// Get database connection
|
||||||
mariaDB, err := s.connMgr.GetDB()
|
mariaDB, err := s.getDB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("database not available: %w", err)
|
return 0, fmt.Errorf("database not available: %w", err)
|
||||||
}
|
}
|
||||||
@@ -301,6 +520,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)
|
||||||
@@ -314,8 +540,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)
|
||||||
@@ -342,6 +569,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:
|
||||||
@@ -349,6 +578,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 {
|
||||||
@@ -356,6 +674,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:
|
||||||
@@ -365,23 +689,44 @@ 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
|
// Get database connection
|
||||||
mariaDB, err := s.connMgr.GetDB()
|
mariaDB, err := s.getDB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("database not available: %w", err)
|
return fmt.Errorf("database not available: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create repository
|
// Create repository
|
||||||
configRepo := repository.NewConfigurationRepository(mariaDB)
|
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)
|
||||||
|
}
|
||||||
|
if err := s.ensureConfigurationPricelist(mariaDB, &cfg); err != nil {
|
||||||
|
return fmt.Errorf("resolve configuration pricelist: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Create on server
|
// Create on server
|
||||||
if err := configRepo.Create(&cfg); err != nil {
|
if err := configRepo.Create(&cfg); err != nil {
|
||||||
return fmt.Errorf("creating configuration on server: %w", err)
|
// 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)
|
||||||
|
}
|
||||||
|
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
|
||||||
@@ -393,25 +738,44 @@ 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
|
// Get database connection
|
||||||
mariaDB, err := s.connMgr.GetDB()
|
mariaDB, err := s.getDB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("database not available: %w", err)
|
return fmt.Errorf("database not available: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create repository
|
// Create repository
|
||||||
configRepo := repository.NewConfigurationRepository(mariaDB)
|
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)
|
||||||
|
}
|
||||||
|
if err := s.ensureConfigurationPricelist(mariaDB, &cfg); err != nil {
|
||||||
|
return fmt.Errorf("resolve configuration pricelist: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure we have a server ID before updating
|
// Ensure we have a server ID before updating
|
||||||
// If the payload doesn't have ID, get it from local configuration
|
// If the payload doesn't have ID, get it from local configuration
|
||||||
@@ -422,15 +786,34 @@ 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 := configRepo.GetByUUID(cfg.UUID)
|
// If not found (e.g. stale create was skipped), create it from current snapshot.
|
||||||
if err != nil {
|
serverCfg, getErr := configRepo.GetByUUID(cfg.UUID)
|
||||||
return fmt.Errorf("configuration not yet synced to server: %w", err)
|
if getErr != nil {
|
||||||
|
if !errors.Is(getErr, gorm.ErrRecordNotFound) {
|
||||||
|
return fmt.Errorf("loading configuration from server: %w", getErr)
|
||||||
|
}
|
||||||
|
if createErr := configRepo.Create(&cfg); createErr != nil {
|
||||||
|
// Idempotency fallback: configuration may have been created concurrently.
|
||||||
|
existing, existingErr := configRepo.GetByUUID(cfg.UUID)
|
||||||
|
if existingErr != nil {
|
||||||
|
return fmt.Errorf("creating missing configuration on server: %w", createErr)
|
||||||
|
}
|
||||||
|
cfg.ID = existing.ID
|
||||||
|
}
|
||||||
|
if cfg.ID == 0 {
|
||||||
|
existing, existingErr := configRepo.GetByUUID(cfg.UUID)
|
||||||
|
if existingErr != nil {
|
||||||
|
return fmt.Errorf("loading created configuration from server: %w", existingErr)
|
||||||
|
}
|
||||||
|
cfg.ID = existing.ID
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cfg.ID = serverCfg.ID
|
||||||
}
|
}
|
||||||
cfg.ID = serverCfg.ID
|
|
||||||
|
|
||||||
// Update local with server ID
|
// Update local with server ID
|
||||||
serverID := serverCfg.ID
|
serverID := cfg.ID
|
||||||
localCfg.ServerID = &serverID
|
localCfg.ServerID = &serverID
|
||||||
s.localDB.SaveConfiguration(localCfg)
|
s.localDB.SaveConfiguration(localCfg)
|
||||||
} else {
|
} else {
|
||||||
@@ -450,14 +833,267 @@ 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) ensureConfigurationPricelist(mariaDB *gorm.DB, cfg *models.Configuration) error {
|
||||||
|
if cfg == nil {
|
||||||
|
return fmt.Errorf("configuration is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
||||||
|
|
||||||
|
if cfg.PricelistID != nil && *cfg.PricelistID > 0 {
|
||||||
|
if _, err := pricelistRepo.GetByID(*cfg.PricelistID); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
latest, err := pricelistRepo.GetLatestActive()
|
||||||
|
if err != nil {
|
||||||
|
cfg.PricelistID = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.PricelistID = &latest.ID
|
||||||
|
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
|
||||||
|
}
|
||||||
|
payload.PricelistID = currentCfg.PricelistID
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
PricelistID: cfg.PricelistID,
|
||||||
|
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
|
// Get database connection
|
||||||
mariaDB, err := s.connMgr.GetDB()
|
mariaDB, err := s.getDB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("database not available: %w", err)
|
return fmt.Errorf("database not available: %w", err)
|
||||||
}
|
}
|
||||||
@@ -478,6 +1114,26 @@ func (s *Service) pushConfigurationDelete(change *localdb.PendingChange) error {
|
|||||||
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()
|
||||||
|
}
|
||||||
|
|||||||
25
internal/services/sync/service_order_test.go
Normal file
25
internal/services/sync/service_order_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
325
internal/services/sync/service_projects_push_test.go
Normal file
325
internal/services/sync/service_projects_push_test.go
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
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 TestPushPendingChangesCreateThenUpdateBeforeFirstPush(t *testing.T) {
|
||||||
|
local := newLocalDBForSyncTest(t)
|
||||||
|
serverDB := newServerDBForSyncTest(t)
|
||||||
|
|
||||||
|
localSync := syncsvc.NewService(nil, local)
|
||||||
|
configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false })
|
||||||
|
pushService := syncsvc.NewServiceWithDB(serverDB, local)
|
||||||
|
|
||||||
|
created, err := configService.Create("tester", &services.CreateConfigRequest{
|
||||||
|
Name: "Cfg v1",
|
||||||
|
Items: models.ConfigItems{{LotName: "CPU_X", Quantity: 1, UnitPrice: 700}},
|
||||||
|
ServerCount: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := configService.UpdateNoAuth(created.UUID, &services.CreateConfigRequest{
|
||||||
|
Name: "Cfg v2",
|
||||||
|
Items: models.ConfigItems{{LotName: "CPU_X", Quantity: 3, UnitPrice: 700}},
|
||||||
|
ServerCount: 1,
|
||||||
|
ProjectUUID: created.ProjectUUID,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("update config before first push: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pushed, err := pushService.PushPendingChanges()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("push pending changes: %v", err)
|
||||||
|
}
|
||||||
|
if pushed < 1 {
|
||||||
|
t.Fatalf("expected at least one pushed change, got %d", pushed)
|
||||||
|
}
|
||||||
|
|
||||||
|
var serverCfg models.Configuration
|
||||||
|
if err := serverDB.Where("uuid = ?", created.UUID).First(&serverCfg).Error; err != nil {
|
||||||
|
t.Fatalf("configuration not pushed to server: %v", err)
|
||||||
|
}
|
||||||
|
if serverCfg.Name != "Cfg v2" {
|
||||||
|
t.Fatalf("expected latest update to be pushed, got %q", serverCfg.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
localCfg, err := local.GetConfigurationByUUID(created.UUID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get local config: %v", err)
|
||||||
|
}
|
||||||
|
if localCfg.ServerID == nil || *localCfg.ServerID == 0 {
|
||||||
|
t.Fatalf("expected local configuration to have server_id after push, got %+v", localCfg.ServerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLocalDBForSyncTest(t *testing.T) *localdb.LocalDB {
|
||||||
|
t.Helper()
|
||||||
|
localPath := filepath.Join(t.TempDir(), "local.db")
|
||||||
|
local, err := localdb.New(localPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("init local db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = local.Close() })
|
||||||
|
return local
|
||||||
|
}
|
||||||
|
|
||||||
|
func newServerDBForSyncTest(t *testing.T) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
serverPath := filepath.Join(t.TempDir(), "server.db")
|
||||||
|
db, err := gorm.Open(sqlite.Open(serverPath), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open server sqlite: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Exec(`
|
||||||
|
CREATE TABLE qt_projects (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
uuid TEXT NOT NULL UNIQUE,
|
||||||
|
owner_username TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
is_system INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME,
|
||||||
|
updated_at DATETIME
|
||||||
|
);`).Error; err != nil {
|
||||||
|
t.Fatalf("create qt_projects: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Exec(`
|
||||||
|
CREATE TABLE qt_configurations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
uuid TEXT NOT NULL UNIQUE,
|
||||||
|
user_id INTEGER NULL,
|
||||||
|
owner_username TEXT NOT NULL,
|
||||||
|
project_uuid TEXT NULL,
|
||||||
|
app_version TEXT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
items TEXT NOT NULL,
|
||||||
|
total_price REAL NULL,
|
||||||
|
custom_price REAL NULL,
|
||||||
|
notes TEXT NULL,
|
||||||
|
is_template INTEGER NOT NULL DEFAULT 0,
|
||||||
|
server_count INTEGER NOT NULL DEFAULT 1,
|
||||||
|
pricelist_id INTEGER NULL,
|
||||||
|
price_updated_at DATETIME NULL,
|
||||||
|
created_at DATETIME
|
||||||
|
);`).Error; err != nil {
|
||||||
|
t.Fatalf("create qt_configurations: %v", err)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCurrentVersionInfo(t *testing.T, local *localdb.LocalDB, configurationUUID string, currentVersionID *string) (int, string) {
|
||||||
|
t.Helper()
|
||||||
|
if currentVersionID == nil || *currentVersionID == "" {
|
||||||
|
t.Fatalf("current version id is empty for %s", configurationUUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var version localdb.LocalConfigurationVersion
|
||||||
|
if err := local.DB().
|
||||||
|
Where("id = ? AND configuration_uuid = ?", *currentVersionID, configurationUUID).
|
||||||
|
First(&version).Error; err != nil {
|
||||||
|
t.Fatalf("get current version info: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return version.VersionNo, version.ID
|
||||||
|
}
|
||||||
@@ -83,7 +83,11 @@ func (w *Worker) runSync() {
|
|||||||
err = w.service.SyncPricelistsIfNeeded()
|
err = w.service.SyncPricelistsIfNeeded()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.logger.Warn("background sync: failed to sync pricelists", "error", err)
|
w.logger.Warn("background sync: failed to sync pricelists", "error", err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark user's sync heartbeat (used for online/offline status in UI).
|
||||||
|
w.service.RecordSyncHeartbeat()
|
||||||
|
|
||||||
w.logger.Info("background sync cycle completed")
|
w.logger.Info("background sync cycle completed")
|
||||||
}
|
}
|
||||||
|
|||||||
10
migrations/005_add_owner_username.sql
Normal file
10
migrations/005_add_owner_username.sql
Normal 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 = '';
|
||||||
80
migrations/006_add_local_configuration_versions.sql
Normal file
80
migrations/006_add_local_configuration_versions.sql
Normal 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;
|
||||||
25
migrations/007_detach_configurations_from_qt_users.sql
Normal file
25
migrations/007_detach_configurations_from_qt_users.sql
Normal 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;
|
||||||
|
|
||||||
4
migrations/008_add_app_version_to_configurations.sql
Normal file
4
migrations/008_add_app_version_to_configurations.sql
Normal 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;
|
||||||
|
|
||||||
45
migrations/009_add_projects.sql
Normal file
45
migrations/009_add_projects.sql
Normal 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 = '';
|
||||||
8
migrations/010_add_pricelist_sync_status.sql
Normal file
8
migrations/010_add_pricelist_sync_status.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS qt_pricelist_sync_status (
|
||||||
|
username VARCHAR(100) NOT NULL,
|
||||||
|
last_sync_at DATETIME NOT NULL,
|
||||||
|
updated_at DATETIME NOT NULL,
|
||||||
|
app_version VARCHAR(64) NULL,
|
||||||
|
PRIMARY KEY (username),
|
||||||
|
INDEX idx_qt_pricelist_sync_status_last_sync (last_sync_at)
|
||||||
|
);
|
||||||
37
migrations/010_add_pricelist_to_configurations.sql
Normal file
37
migrations/010_add_pricelist_to_configurations.sql
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
-- Add pricelist binding to configurations
|
||||||
|
ALTER TABLE qt_configurations
|
||||||
|
ADD COLUMN pricelist_id BIGINT UNSIGNED NULL AFTER server_count;
|
||||||
|
|
||||||
|
ALTER TABLE qt_configurations
|
||||||
|
ADD INDEX idx_qt_configurations_pricelist_id (pricelist_id),
|
||||||
|
ADD CONSTRAINT fk_qt_configurations_pricelist_id
|
||||||
|
FOREIGN KEY (pricelist_id)
|
||||||
|
REFERENCES qt_pricelists(id)
|
||||||
|
ON DELETE RESTRICT
|
||||||
|
ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Backfill existing configurations to latest active pricelist
|
||||||
|
SET @latest_active_pricelist_id := (
|
||||||
|
SELECT id
|
||||||
|
FROM qt_pricelists
|
||||||
|
WHERE is_active = 1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE qt_configurations
|
||||||
|
SET pricelist_id = @latest_active_pricelist_id
|
||||||
|
WHERE pricelist_id IS NULL
|
||||||
|
AND @latest_active_pricelist_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Recalculate usage_count from configuration bindings
|
||||||
|
UPDATE qt_pricelists SET usage_count = 0;
|
||||||
|
|
||||||
|
UPDATE qt_pricelists pl
|
||||||
|
JOIN (
|
||||||
|
SELECT pricelist_id, COUNT(*) AS cnt
|
||||||
|
FROM qt_configurations
|
||||||
|
WHERE pricelist_id IS NOT NULL
|
||||||
|
GROUP BY pricelist_id
|
||||||
|
) cfg ON cfg.pricelist_id = pl.id
|
||||||
|
SET pl.usage_count = cfg.cnt;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE qt_pricelist_sync_status
|
||||||
|
ADD COLUMN IF NOT EXISTS app_version VARCHAR(64) NULL;
|
||||||
51
releases/v1.0.3/RELEASE_NOTES.md
Normal file
51
releases/v1.0.3/RELEASE_NOTES.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# QuoteForge v1.0.3
|
||||||
|
|
||||||
|
Дата релиза: 2026-02-06
|
||||||
|
Тег: `v1.0.3`
|
||||||
|
Диапазон изменений: `v1.0.2..v1.0.3`
|
||||||
|
|
||||||
|
## Что нового
|
||||||
|
|
||||||
|
- Добавлена страница управления проектами `/projects` с:
|
||||||
|
- датой и временем создания проекта;
|
||||||
|
- сортировкой по названию и дате создания;
|
||||||
|
- серверной пагинацией;
|
||||||
|
- фильтром по автору в заголовке таблицы.
|
||||||
|
- Добавлена отдельная вкладка `Статус синхронизации` на уровне `Алерты / Компоненты / Прайслисты`.
|
||||||
|
- Во вкладке статуса синхронизации отображаются:
|
||||||
|
- пользователь;
|
||||||
|
- версия приложения;
|
||||||
|
- статус (`онлайн` или относительное время последней синхронизации).
|
||||||
|
|
||||||
|
## Изменения синхронизации
|
||||||
|
|
||||||
|
- Реализован heartbeat синхронизации пользователей в MariaDB: `qt_pricelist_sync_status`.
|
||||||
|
- Добавлен API `GET /api/sync/users-status` для UI статуса синхронизации.
|
||||||
|
- Логика онлайн-статуса рассчитана от интервала фоновой синхронизации: `5 минут + 10%`.
|
||||||
|
- В heartbeat фиксируется версия приложения (`app_version`).
|
||||||
|
|
||||||
|
## Важные исправления
|
||||||
|
|
||||||
|
- Исправлено восстановление отсутствующей серверной конфигурации при push обновлений.
|
||||||
|
- Исправлено экранирование паролей в MySQL DSN в setup.
|
||||||
|
- Улучшена логика запуска SQL-миграций на старте при отсутствии прав/необходимости.
|
||||||
|
- Обновлена логика пересчета прайслистов через админский price-refresh.
|
||||||
|
|
||||||
|
## Миграции и совместимость
|
||||||
|
|
||||||
|
Добавлены SQL-миграции:
|
||||||
|
|
||||||
|
- `migrations/010_add_pricelist_sync_status.sql`
|
||||||
|
- `migrations/011_add_app_version_to_pricelist_sync_status.sql`
|
||||||
|
|
||||||
|
Релиз совместим с предыдущей веткой `v1.0.x`; новая таблица синхронизации создается автоматически.
|
||||||
|
|
||||||
|
## Коммиты в релизе
|
||||||
|
|
||||||
|
- `b1b50ce` Add projects table controls and sync status tab with app version
|
||||||
|
- `6ab1e98` sync: recover missing server config during update push
|
||||||
|
- `a1d2192` Fix MySQL DSN escaping for setup passwords and clarify DB user setup
|
||||||
|
- `a90c07c` update stale files list
|
||||||
|
- `e9307c4` Apply remaining pricelist and local-first updates
|
||||||
|
- `1b48401` Use admin price-refresh logic for pricelist recalculation
|
||||||
|
- `4a86f7b` fix: skip startup sql migrations when not needed or no permissions
|
||||||
@@ -10,17 +10,18 @@
|
|||||||
<button onclick="loadTab('alerts')" id="btn-alerts" class="text-blue-600 font-medium">Алерты</button>
|
<button onclick="loadTab('alerts')" id="btn-alerts" class="text-blue-600 font-medium">Алерты</button>
|
||||||
<button onclick="loadTab('components')" id="btn-components" class="text-gray-600">Компоненты</button>
|
<button onclick="loadTab('components')" id="btn-components" class="text-gray-600">Компоненты</button>
|
||||||
<button onclick="loadTab('pricelists')" id="btn-pricelists" class="text-gray-600">Прайслисты</button>
|
<button onclick="loadTab('pricelists')" id="btn-pricelists" class="text-gray-600">Прайслисты</button>
|
||||||
|
<button onclick="loadTab('sync-status')" id="btn-sync-status" class="text-gray-600 hidden">Статус синхронизации</button>
|
||||||
<button onclick="loadTab('all-configs')" id="btn-all-configs" class="text-gray-600 hidden">Все конфигурации</button>
|
<button onclick="loadTab('all-configs')" id="btn-all-configs" class="text-gray-600 hidden">Все конфигурации</button>
|
||||||
</div>
|
</div>
|
||||||
<button onclick="recalculateAll()" id="btn-recalc" class="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700">
|
<button onclick="recalculateAll()" id="btn-recalc" class="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700">
|
||||||
Пересчитать цены
|
Обновить цены
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Progress bar -->
|
<!-- Progress bar -->
|
||||||
<div id="progress-container" class="mb-4 p-4 bg-blue-50 rounded-lg border border-blue-200" style="display:none;">
|
<div id="progress-container" class="mb-4 p-4 bg-blue-50 rounded-lg border border-blue-200" style="display:none;">
|
||||||
<div class="flex justify-between text-sm text-gray-700 mb-2">
|
<div class="flex justify-between text-sm text-gray-700 mb-2">
|
||||||
<span id="progress-text" class="font-medium">Пересчёт цен...</span>
|
<span id="progress-text" class="font-medium">Обновление цен...</span>
|
||||||
<span id="progress-percent" class="font-bold">0%</span>
|
<span id="progress-percent" class="font-bold">0%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full bg-gray-200 rounded-full h-4">
|
<div class="w-full bg-gray-200 rounded-full h-4">
|
||||||
@@ -85,6 +86,30 @@
|
|||||||
<div id="pricelists-pagination" class="flex justify-center space-x-2 mt-4"></div>
|
<div id="pricelists-pagination" class="flex justify-center space-x-2 mt-4"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Sync Status Tab Content (hidden by default) -->
|
||||||
|
<div id="sync-status-tab-content" class="hidden">
|
||||||
|
<div class="mb-4">
|
||||||
|
<h2 class="text-xl font-semibold">Статус синхронизации</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Пользователь</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Версия приложения</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Статус</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="sync-users-status-body" class="bg-white divide-y divide-gray-200">
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="px-6 py-4 text-sm text-gray-500">Загрузка...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Create Modal -->
|
<!-- Create Modal -->
|
||||||
<div id="pricelists-create-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
<div id="pricelists-create-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||||
<div class="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
<div class="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
||||||
@@ -94,6 +119,16 @@
|
|||||||
Автор прайслиста: <span id="pricelists-db-username" class="font-medium">загрузка...</span>
|
Автор прайслиста: <span id="pricelists-db-username" class="font-medium">загрузка...</span>
|
||||||
</p>
|
</p>
|
||||||
<form id="pricelists-create-form" class="space-y-4">
|
<form id="pricelists-create-form" class="space-y-4">
|
||||||
|
<div id="pricelist-create-progress" class="hidden p-3 bg-blue-50 rounded-lg border border-blue-200">
|
||||||
|
<div class="flex justify-between items-center text-sm mb-2">
|
||||||
|
<span id="pricelist-create-progress-text" class="font-medium">Подготовка...</span>
|
||||||
|
<span id="pricelist-create-progress-percent" class="font-bold">0%</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-blue-100 rounded-full h-3 overflow-hidden">
|
||||||
|
<div id="pricelist-create-progress-bar" class="bg-blue-600 h-3 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
<div id="pricelist-create-progress-stats" class="text-xs text-gray-600 mt-2"></div>
|
||||||
|
</div>
|
||||||
<div class="flex justify-end space-x-3">
|
<div class="flex justify-end space-x-3">
|
||||||
<button type="button" onclick="closePricelistsCreateModal()"
|
<button type="button" onclick="closePricelistsCreateModal()"
|
||||||
class="px-4 py-2 text-gray-700 border border-gray-300 rounded-md hover:bg-gray-50">
|
class="px-4 py-2 text-gray-700 border border-gray-300 rounded-md hover:bg-gray-50">
|
||||||
@@ -216,16 +251,21 @@ let pricelistsPage = 1;
|
|||||||
let pricelistsCanWrite = false;
|
let pricelistsCanWrite = false;
|
||||||
let isCreatingPricelist = false;
|
let isCreatingPricelist = false;
|
||||||
let cachedDbUsername = null;
|
let cachedDbUsername = null;
|
||||||
|
let syncUsersStatusTimer = null;
|
||||||
|
|
||||||
async function loadTab(tab) {
|
async function loadTab(tab) {
|
||||||
currentTab = tab;
|
currentTab = tab;
|
||||||
currentPage = 1;
|
currentPage = 1;
|
||||||
currentSearch = '';
|
currentSearch = '';
|
||||||
document.getElementById('search-input').value = '';
|
document.getElementById('search-input').value = '';
|
||||||
|
if (tab !== 'sync-status') {
|
||||||
|
stopSyncUsersStatusRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('btn-alerts').className = tab === 'alerts' ? 'text-blue-600 font-medium' : 'text-gray-600';
|
document.getElementById('btn-alerts').className = tab === 'alerts' ? 'text-blue-600 font-medium' : 'text-gray-600';
|
||||||
document.getElementById('btn-components').className = tab === 'components' ? 'text-blue-600 font-medium' : 'text-gray-600';
|
document.getElementById('btn-components').className = tab === 'components' ? 'text-blue-600 font-medium' : 'text-gray-600';
|
||||||
document.getElementById('btn-pricelists').className = tab === 'pricelists' ? 'text-blue-600 font-medium' : 'text-gray-600';
|
document.getElementById('btn-pricelists').className = tab === 'pricelists' ? 'text-blue-600 font-medium' : 'text-gray-600';
|
||||||
|
document.getElementById('btn-sync-status').className = (tab === 'sync-status' ? 'text-blue-600 font-medium' : 'text-gray-600') + (pricelistsCanWrite ? '' : ' hidden');
|
||||||
document.getElementById('btn-all-configs').className = tab === 'all-configs' ? 'text-blue-600 font-medium' : 'text-gray-600 hidden';
|
document.getElementById('btn-all-configs').className = tab === 'all-configs' ? 'text-blue-600 font-medium' : 'text-gray-600 hidden';
|
||||||
|
|
||||||
// Show/hide elements based on tab
|
// Show/hide elements based on tab
|
||||||
@@ -234,35 +274,69 @@ async function loadTab(tab) {
|
|||||||
document.getElementById('pagination').className = 'flex justify-between items-center mt-4 pt-4 border-t';
|
document.getElementById('pagination').className = 'flex justify-between items-center mt-4 pt-4 border-t';
|
||||||
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden'; // Hide this tab for components
|
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden'; // Hide this tab for components
|
||||||
document.getElementById('pricelists-tab-content').className = 'hidden';
|
document.getElementById('pricelists-tab-content').className = 'hidden';
|
||||||
|
document.getElementById('sync-status-tab-content').className = 'hidden';
|
||||||
document.getElementById('tab-content').className = '';
|
document.getElementById('tab-content').className = '';
|
||||||
} else if (tab === 'all-configs') {
|
} else if (tab === 'all-configs') {
|
||||||
document.getElementById('search-bar').className = 'mb-4 hidden'; // Hide search for all configs
|
document.getElementById('search-bar').className = 'mb-4 hidden'; // Hide search for all configs
|
||||||
document.getElementById('pagination').className = 'flex justify-between items-center mt-4 pt-4 border-t'; // Show pagination
|
document.getElementById('pagination').className = 'flex justify-between items-center mt-4 pt-4 border-t'; // Show pagination
|
||||||
document.getElementById('btn-all-configs').className = 'text-blue-600 font-medium'; // Show this tab for all configs
|
document.getElementById('btn-all-configs').className = 'text-blue-600 font-medium'; // Show this tab for all configs
|
||||||
document.getElementById('pricelists-tab-content').className = 'hidden';
|
document.getElementById('pricelists-tab-content').className = 'hidden';
|
||||||
|
document.getElementById('sync-status-tab-content').className = 'hidden';
|
||||||
document.getElementById('tab-content').className = '';
|
document.getElementById('tab-content').className = '';
|
||||||
} else if (tab === 'pricelists') {
|
} else if (tab === 'pricelists') {
|
||||||
document.getElementById('search-bar').className = 'mb-4 hidden';
|
document.getElementById('search-bar').className = 'mb-4 hidden';
|
||||||
document.getElementById('pagination').className = 'hidden';
|
document.getElementById('pagination').className = 'hidden';
|
||||||
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
|
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
|
||||||
document.getElementById('pricelists-tab-content').className = '';
|
document.getElementById('pricelists-tab-content').className = '';
|
||||||
|
document.getElementById('sync-status-tab-content').className = 'hidden';
|
||||||
document.getElementById('tab-content').className = 'hidden';
|
document.getElementById('tab-content').className = 'hidden';
|
||||||
// Load pricelists when pricelists tab is selected
|
// Load pricelists when pricelists tab is selected
|
||||||
checkPricelistWritePermission();
|
checkPricelistWritePermission();
|
||||||
loadPricelists(1);
|
loadPricelists(1);
|
||||||
|
} else if (tab === 'sync-status') {
|
||||||
|
document.getElementById('search-bar').className = 'mb-4 hidden';
|
||||||
|
document.getElementById('pagination').className = 'hidden';
|
||||||
|
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
|
||||||
|
document.getElementById('pricelists-tab-content').className = 'hidden';
|
||||||
|
document.getElementById('sync-status-tab-content').className = '';
|
||||||
|
document.getElementById('tab-content').className = 'hidden';
|
||||||
|
await checkPricelistWritePermission();
|
||||||
|
if (!pricelistsCanWrite) {
|
||||||
|
await loadTab('alerts');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadUsersSyncStatus();
|
||||||
|
startSyncUsersStatusRefresh();
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('search-bar').className = 'mb-4 hidden';
|
document.getElementById('search-bar').className = 'mb-4 hidden';
|
||||||
document.getElementById('pagination').className = 'hidden';
|
document.getElementById('pagination').className = 'hidden';
|
||||||
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
|
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
|
||||||
document.getElementById('pricelists-tab-content').className = 'hidden';
|
document.getElementById('pricelists-tab-content').className = 'hidden';
|
||||||
|
document.getElementById('sync-status-tab-content').className = 'hidden';
|
||||||
document.getElementById('tab-content').className = '';
|
document.getElementById('tab-content').className = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tab !== 'pricelists') {
|
if (tab !== 'pricelists' && tab !== 'sync-status') {
|
||||||
await loadData();
|
await loadData();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stopSyncUsersStatusRefresh() {
|
||||||
|
if (syncUsersStatusTimer) {
|
||||||
|
clearInterval(syncUsersStatusTimer);
|
||||||
|
syncUsersStatusTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSyncUsersStatusRefresh() {
|
||||||
|
stopSyncUsersStatusRefresh();
|
||||||
|
syncUsersStatusTimer = setInterval(() => {
|
||||||
|
if (currentTab === 'sync-status' && pricelistsCanWrite) {
|
||||||
|
loadUsersSyncStatus();
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-gray-500">Загрузка...</div>';
|
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-gray-500">Загрузка...</div>';
|
||||||
|
|
||||||
@@ -750,11 +824,11 @@ function recalculateAll() {
|
|||||||
|
|
||||||
// Show progress bar IMMEDIATELY
|
// Show progress bar IMMEDIATELY
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Пересчёт...';
|
btn.textContent = 'Обновление...';
|
||||||
progressContainer.style.display = 'block';
|
progressContainer.style.display = 'block';
|
||||||
progressBar.style.width = '0%';
|
progressBar.style.width = '0%';
|
||||||
progressBar.className = 'bg-blue-600 h-4 rounded-full transition-all duration-300';
|
progressBar.className = 'bg-blue-600 h-4 rounded-full transition-all duration-300';
|
||||||
progressText.textContent = 'Запуск пересчёта...';
|
progressText.textContent = 'Запуск обновления...';
|
||||||
progressPercent.textContent = '0%';
|
progressPercent.textContent = '0%';
|
||||||
progressStats.textContent = 'Подготовка...';
|
progressStats.textContent = 'Подготовка...';
|
||||||
|
|
||||||
@@ -769,7 +843,7 @@ function recalculateAll() {
|
|||||||
reader.read().then(({done, value}) => {
|
reader.read().then(({done, value}) => {
|
||||||
if (done) {
|
if (done) {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = 'Пересчитать цены';
|
btn.textContent = 'Обновить цены';
|
||||||
progressText.textContent = 'Готово!';
|
progressText.textContent = 'Готово!';
|
||||||
progressBar.className = 'bg-green-600 h-4 rounded-full';
|
progressBar.className = 'bg-green-600 h-4 rounded-full';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -794,7 +868,7 @@ function recalculateAll() {
|
|||||||
progressPercent.textContent = percent + '%';
|
progressPercent.textContent = percent + '%';
|
||||||
|
|
||||||
if (data.status === 'completed') {
|
if (data.status === 'completed') {
|
||||||
progressText.textContent = 'Пересчёт завершён!';
|
progressText.textContent = 'Обновление завершено!';
|
||||||
progressBar.className = 'bg-green-600 h-4 rounded-full';
|
progressBar.className = 'bg-green-600 h-4 rounded-full';
|
||||||
} else {
|
} else {
|
||||||
progressText.textContent = data.lot_name ? 'Обработка: ' + data.lot_name : 'Обработка компонентов...';
|
progressText.textContent = data.lot_name ? 'Обработка: ' + data.lot_name : 'Обработка компонентов...';
|
||||||
@@ -816,7 +890,7 @@ function recalculateAll() {
|
|||||||
console.error('Fetch error:', e);
|
console.error('Fetch error:', e);
|
||||||
alert('Ошибка соединения');
|
alert('Ошибка соединения');
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = 'Пересчитать цены';
|
btn.textContent = 'Обновить цены';
|
||||||
progressContainer.style.display = 'none';
|
progressContainer.style.display = 'none';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -861,7 +935,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>';
|
||||||
@@ -892,11 +966,12 @@ function renderAllConfigs(configs) {
|
|||||||
document.getElementById('tab-content').innerHTML = html;
|
document.getElementById('tab-content').innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
await checkPricelistWritePermission();
|
||||||
// Check URL params for initial tab
|
// Check URL params for initial tab
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const initialTab = urlParams.get('tab') || 'alerts';
|
const initialTab = urlParams.get('tab') || 'alerts';
|
||||||
loadTab(initialTab);
|
await loadTab(initialTab);
|
||||||
|
|
||||||
// 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);
|
||||||
@@ -920,9 +995,89 @@ async function checkPricelistWritePermission() {
|
|||||||
Создать прайслист
|
Создать прайслист
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
|
document.getElementById('btn-sync-status').classList.remove('hidden');
|
||||||
|
if (currentTab === 'sync-status') {
|
||||||
|
await loadUsersSyncStatus();
|
||||||
|
startSyncUsersStatusRefresh();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
document.getElementById('pricelists-create-btn-container').innerHTML = '';
|
||||||
|
document.getElementById('btn-sync-status').classList.add('hidden');
|
||||||
|
stopSyncUsersStatusRefresh();
|
||||||
|
if (currentTab === 'sync-status') {
|
||||||
|
await loadTab('alerts');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to check pricelist write permission:', e);
|
console.error('Failed to check pricelist write permission:', e);
|
||||||
|
document.getElementById('btn-sync-status').classList.add('hidden');
|
||||||
|
stopSyncUsersStatusRefresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(lastSyncAt) {
|
||||||
|
const timestamp = new Date(lastSyncAt);
|
||||||
|
if (Number.isNaN(timestamp.getTime())) return '—';
|
||||||
|
const diffMinutes = Math.max(1, Math.floor((Date.now() - timestamp.getTime()) / 60000));
|
||||||
|
if (diffMinutes < 60) return `${diffMinutes} мин назад`;
|
||||||
|
const diffHours = Math.floor(diffMinutes / 60);
|
||||||
|
if (diffHours < 24) return `${diffHours} ч назад`;
|
||||||
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
|
if (diffDays < 7) return `${diffDays} дн назад`;
|
||||||
|
const diffWeeks = Math.floor(diffDays / 7);
|
||||||
|
if (diffWeeks < 5) return `${diffWeeks} нед назад`;
|
||||||
|
const diffMonths = Math.floor(diffDays / 30);
|
||||||
|
if (diffMonths < 12) return `${diffMonths} мес назад`;
|
||||||
|
const diffYears = Math.floor(diffDays / 365);
|
||||||
|
return `${diffYears} г назад`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsersSyncStatus() {
|
||||||
|
if (!pricelistsCanWrite) return;
|
||||||
|
|
||||||
|
const body = document.getElementById('sync-users-status-body');
|
||||||
|
if (!body) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/sync/users-status');
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(data.error || 'Ошибка загрузки');
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = data.users || [];
|
||||||
|
if (users.length === 0) {
|
||||||
|
body.innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="px-6 py-4 text-sm text-gray-500">
|
||||||
|
Нет данных о синхронизации пользователей
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.innerHTML = users.map(u => {
|
||||||
|
const statusClass = u.is_online ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-700';
|
||||||
|
const statusText = u.is_online ? 'онлайн' : formatRelativeTime(u.last_sync_at);
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">${escapeHtml(u.username || '—')}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">${escapeHtml(u.app_version || '—')}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||||
|
<span class="px-2 py-1 text-xs rounded-full ${statusClass}">${statusText}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
} catch (e) {
|
||||||
|
body.innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="px-6 py-4 text-sm text-red-600">
|
||||||
|
Ошибка загрузки статусов синхронизации: ${escapeHtml(e.message || String(e))}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -965,6 +1120,10 @@ function renderPricelists(pricelists) {
|
|||||||
const statusText = pl.is_active ? 'Активен' : 'Неактивен';
|
const statusText = pl.is_active ? 'Активен' : 'Неактивен';
|
||||||
|
|
||||||
let actions = `<a href="/pricelists/${pl.id}" class="text-blue-600 hover:text-blue-800 text-sm">Просмотр</a>`;
|
let actions = `<a href="/pricelists/${pl.id}" class="text-blue-600 hover:text-blue-800 text-sm">Просмотр</a>`;
|
||||||
|
if (pricelistsCanWrite) {
|
||||||
|
const toggleLabel = pl.is_active ? 'Деактивировать' : 'Активировать';
|
||||||
|
actions += ` <button onclick="togglePricelistActive(${pl.id}, ${pl.is_active ? 'false' : 'true'})" class="text-indigo-600 hover:text-indigo-800 text-sm ml-2">${toggleLabel}</button>`;
|
||||||
|
}
|
||||||
if (pricelistsCanWrite && pl.usage_count === 0) {
|
if (pricelistsCanWrite && pl.usage_count === 0) {
|
||||||
actions += ` <button onclick="deletePricelist(${pl.id})" class="text-red-600 hover:text-red-800 text-sm ml-2">Удалить</button>`;
|
actions += ` <button onclick="deletePricelist(${pl.id})" class="text-red-600 hover:text-red-800 text-sm ml-2">Удалить</button>`;
|
||||||
}
|
}
|
||||||
@@ -989,6 +1148,33 @@ function renderPricelists(pricelists) {
|
|||||||
document.getElementById('pricelists-body').innerHTML = html;
|
document.getElementById('pricelists-body').innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function togglePricelistActive(id, isActive) {
|
||||||
|
// Check if online before toggling
|
||||||
|
const isOnline = await checkOnlineStatus();
|
||||||
|
if (!isOnline) {
|
||||||
|
showToast('Изменение статуса прайслиста доступно только в онлайн режиме', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/pricelists/${id}/active`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ is_active: isActive })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const data = await resp.json();
|
||||||
|
throw new Error(data.error || 'Failed to update status');
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast('Статус прайслиста обновлен', 'success');
|
||||||
|
loadPricelists(pricelistsPage);
|
||||||
|
} catch (e) {
|
||||||
|
showToast('Ошибка: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderPricelistsPagination(total, page, perPage) {
|
function renderPricelistsPagination(total, page, perPage) {
|
||||||
const totalPages = Math.ceil(total / perPage);
|
const totalPages = Math.ceil(total / perPage);
|
||||||
if (totalPages <= 1) {
|
if (totalPages <= 1) {
|
||||||
@@ -1024,6 +1210,7 @@ async function loadPricelistsDbUsername() {
|
|||||||
function openPricelistsCreateModal() {
|
function openPricelistsCreateModal() {
|
||||||
document.getElementById('pricelists-create-modal').classList.remove('hidden');
|
document.getElementById('pricelists-create-modal').classList.remove('hidden');
|
||||||
document.getElementById('pricelists-create-modal').classList.add('flex');
|
document.getElementById('pricelists-create-modal').classList.add('flex');
|
||||||
|
resetPricelistCreateProgress();
|
||||||
loadPricelistsDbUsername();
|
loadPricelistsDbUsername();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1049,20 +1236,88 @@ async function createPricelist() {
|
|||||||
throw new Error('Создание прайслистов доступно только в онлайн режиме');
|
throw new Error('Создание прайслистов доступно только в онлайн режиме');
|
||||||
}
|
}
|
||||||
|
|
||||||
const resp = await fetch('/api/pricelists', {
|
const progressBox = document.getElementById('pricelist-create-progress');
|
||||||
method: 'POST',
|
const progressBar = document.getElementById('pricelist-create-progress-bar');
|
||||||
headers: {
|
const progressText = document.getElementById('pricelist-create-progress-text');
|
||||||
'Content-Type': 'application/json'
|
const progressPercent = document.getElementById('pricelist-create-progress-percent');
|
||||||
},
|
const progressStats = document.getElementById('pricelist-create-progress-stats');
|
||||||
body: JSON.stringify({})
|
|
||||||
|
progressBox.classList.remove('hidden');
|
||||||
|
progressBar.style.width = '0%';
|
||||||
|
progressText.textContent = 'Запуск создания прайслиста...';
|
||||||
|
progressPercent.textContent = '0%';
|
||||||
|
progressStats.textContent = '';
|
||||||
|
|
||||||
|
const resp = await fetch('/api/pricelists/create-with-progress', {
|
||||||
|
method: 'POST'
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const data = await resp.json();
|
let data = {};
|
||||||
|
try { data = await resp.json(); } catch (_) {}
|
||||||
throw new Error(data.error || 'Failed to create pricelist');
|
throw new Error(data.error || 'Failed to create pricelist');
|
||||||
}
|
}
|
||||||
|
|
||||||
return await resp.json();
|
const reader = resp.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let completedPricelist = null;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
const text = decoder.decode(value);
|
||||||
|
const lines = text.split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.startsWith('data:')) continue;
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(line.slice(5).trim());
|
||||||
|
} catch (_) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = Number(data.current || 0);
|
||||||
|
const total = Number(data.total || 0);
|
||||||
|
const percent = total > 0 ? Math.round((current / total) * 100) : 0;
|
||||||
|
progressBar.style.width = percent + '%';
|
||||||
|
progressPercent.textContent = percent + '%';
|
||||||
|
if (data.lot_name) {
|
||||||
|
progressText.textContent = (data.message || 'Обработка') + ': ' + data.lot_name;
|
||||||
|
} else {
|
||||||
|
progressText.textContent = data.message || 'Обработка...';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.updated !== undefined || data.errors !== undefined) {
|
||||||
|
progressStats.textContent = 'Обновлено: ' + (data.updated || 0) + ' | Ошибок: ' + (data.errors || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.status === 'error') {
|
||||||
|
throw new Error(data.message || 'Ошибка создания прайслиста');
|
||||||
|
}
|
||||||
|
if (data.status === 'completed' && data.pricelist) {
|
||||||
|
completedPricelist = data.pricelist;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!completedPricelist) {
|
||||||
|
throw new Error('Создание прервано: не получен результат');
|
||||||
|
}
|
||||||
|
return completedPricelist;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPricelistCreateProgress() {
|
||||||
|
const progressBox = document.getElementById('pricelist-create-progress');
|
||||||
|
const progressBar = document.getElementById('pricelist-create-progress-bar');
|
||||||
|
const progressText = document.getElementById('pricelist-create-progress-text');
|
||||||
|
const progressPercent = document.getElementById('pricelist-create-progress-percent');
|
||||||
|
const progressStats = document.getElementById('pricelist-create-progress-stats');
|
||||||
|
progressBox.classList.add('hidden');
|
||||||
|
progressBar.style.width = '0%';
|
||||||
|
progressText.textContent = 'Подготовка...';
|
||||||
|
progressPercent.textContent = '0%';
|
||||||
|
progressStats.textContent = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deletePricelist(id) {
|
async function deletePricelist(id) {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<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">Администратор цен</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>
|
||||||
|
|||||||
@@ -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,26 +231,57 @@ 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>';
|
||||||
html += '<td class="px-4 py-3 text-sm font-medium"><a href="/configurator?uuid=' + c.uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(c.name) + '</a></td>';
|
if (configStatusMode === 'archived') {
|
||||||
|
if (c.project_uuid) {
|
||||||
|
html += '<td class="px-4 py-3 text-sm"><a href="/projects/' + c.project_uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(projectName) + '</a></td>';
|
||||||
|
} else {
|
||||||
|
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(projectName) + '</td>';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (c.project_uuid) {
|
||||||
|
html += '<td class="px-4 py-3 text-sm"><a href="/projects/' + c.project_uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(projectName) + '</a></td>';
|
||||||
|
} else {
|
||||||
|
html += '<td class="px-4 py-3 text-sm text-gray-700">' + escapeHtml(projectName) + '</td>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (configStatusMode === 'archived') {
|
||||||
|
html += '<td class="px-4 py-3 text-sm font-medium text-gray-700">' + escapeHtml(c.name) + '</td>';
|
||||||
|
} else {
|
||||||
|
html += '<td class="px-4 py-3 text-sm font-medium"><a href="/configurator?uuid=' + c.uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(c.name) + '</a></td>';
|
||||||
|
}
|
||||||
|
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(author) + '</td>';
|
||||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + pricePerUnit + '</td>';
|
html += '<td class="px-4 py-3 text-sm text-gray-500">' + 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">';
|
||||||
html += '<button onclick="openCloneModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-green-600 hover:text-green-800" title="Копировать">';
|
if (configStatusMode === 'archived') {
|
||||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
|
html += '<button onclick="reactivateConfig(\'' + c.uuid + '\')" class="text-emerald-600 hover:text-emerald-800" title="Восстановить">';
|
||||||
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>';
|
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
|
||||||
html += '</svg>';
|
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>';
|
||||||
html += '</button>';
|
html += '</svg>';
|
||||||
html += '<button onclick="openRenameModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-blue-600 hover:text-blue-800" title="Переименовать">';
|
html += '</button>';
|
||||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
|
} else {
|
||||||
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 += '<button onclick="openMoveProjectModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\', \'' + (c.project_uuid || '') + '\')" class="text-indigo-600 hover:text-indigo-800" title="Перенести в проект">';
|
||||||
html += '</svg>';
|
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
|
||||||
html += '</button>';
|
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 += '<button onclick="deleteConfig(\'' + c.uuid + '\')" class="text-red-600 hover:text-red-800" title="Удалить">';
|
html += '</svg>';
|
||||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
|
html += '</button>';
|
||||||
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 += '<button onclick="openCloneModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-green-600 hover:text-green-800" title="Копировать">';
|
||||||
html += '</svg>';
|
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
|
||||||
html += '</button>';
|
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>';
|
||||||
|
html += '</svg>';
|
||||||
|
html += '</button>';
|
||||||
|
html += '<button onclick="openRenameModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-blue-600 hover:text-blue-800" title="Переименовать">';
|
||||||
|
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
|
||||||
|
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>';
|
||||||
|
html += '</svg>';
|
||||||
|
html += '</button>';
|
||||||
|
html += '<button onclick="deleteConfig(\'' + c.uuid + '\')" class="text-red-600 hover:text-red-800" title="В архив">';
|
||||||
|
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
|
||||||
|
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>';
|
||||||
|
html += '</svg>';
|
||||||
|
html += '</button>';
|
||||||
|
}
|
||||||
html += '</td></tr>';
|
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;
|
||||||
document.getElementById('pagination').classList.remove('hidden');
|
if (total <= perPage) {
|
||||||
|
document.getElementById('pagination').classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
document.getElementById('pagination').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setConfigStatusMode(mode) {
|
||||||
|
if (mode !== 'active' && mode !== 'archived') return;
|
||||||
|
configStatusMode = mode;
|
||||||
|
currentPage = 1;
|
||||||
|
applyStatusModeUI();
|
||||||
|
loadConfigs();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyStatusModeUI() {
|
||||||
|
const activeBtn = document.getElementById('status-active-btn');
|
||||||
|
const archivedBtn = document.getElementById('status-archived-btn');
|
||||||
|
const actionButtons = document.getElementById('action-buttons');
|
||||||
|
|
||||||
|
if (configStatusMode === 'archived') {
|
||||||
|
activeBtn.className = 'px-4 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50';
|
||||||
|
archivedBtn.className = 'px-4 py-2 text-sm font-medium bg-blue-600 text-white border-l border-gray-200';
|
||||||
|
actionButtons.classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
activeBtn.className = 'px-4 py-2 text-sm font-medium bg-blue-600 text-white';
|
||||||
|
archivedBtn.className = 'px-4 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200';
|
||||||
|
actionButtons.classList.remove('hidden');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load configs with pagination
|
// 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');
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="save-buttons" class="hidden flex items-center space-x-2">
|
<div id="save-buttons" class="hidden flex items-center space-x-2">
|
||||||
<button onclick="refreshPrices()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
<button onclick="refreshPrices()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||||
Пересчитать цену
|
Обновить цены
|
||||||
</button>
|
</button>
|
||||||
<button onclick="saveConfig()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">
|
<button onclick="saveConfig()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">
|
||||||
Сохранить
|
Сохранить
|
||||||
@@ -34,6 +34,14 @@
|
|||||||
class="w-20 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
class="w-20 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||||||
onchange="updateServerCount()">
|
onchange="updateServerCount()">
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Прайслист</label>
|
||||||
|
<select id="pricelist-select"
|
||||||
|
class="w-56 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||||||
|
onchange="updatePricelistSelection()">
|
||||||
|
<option value="">Загрузка...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="text-sm text-gray-500">
|
<div class="text-sm text-gray-500">
|
||||||
<span id="server-count-info">Всего: <span id="total-server-count">1</span> сервер(а)</span>
|
<span id="server-count-info">Всего: <span id="total-server-count">1</span> сервер(а)</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -224,6 +232,7 @@ let cart = [];
|
|||||||
let categoryOrderMap = {}; // Category code -> display_order mapping
|
let categoryOrderMap = {}; // Category code -> display_order mapping
|
||||||
let autoSaveTimeout = null; // Timeout for debounced autosave
|
let autoSaveTimeout = null; // Timeout for debounced autosave
|
||||||
let serverCount = 1; // Server count for the configuration
|
let serverCount = 1; // Server count for the configuration
|
||||||
|
let selectedPricelistId = null; // Selected pricelist (server ID)
|
||||||
|
|
||||||
// Autocomplete state
|
// Autocomplete state
|
||||||
let autocompleteInput = null;
|
let autocompleteInput = null;
|
||||||
@@ -296,6 +305,7 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||||||
serverCount = config.server_count || 1;
|
serverCount = config.server_count || 1;
|
||||||
document.getElementById('server-count').value = serverCount;
|
document.getElementById('server-count').value = serverCount;
|
||||||
document.getElementById('total-server-count').textContent = serverCount;
|
document.getElementById('total-server-count').textContent = serverCount;
|
||||||
|
selectedPricelistId = config.pricelist_id || null;
|
||||||
|
|
||||||
if (config.items && config.items.length > 0) {
|
if (config.items && config.items.length > 0) {
|
||||||
cart = config.items.map(item => ({
|
cart = config.items.map(item => ({
|
||||||
@@ -322,6 +332,7 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await loadActivePricelists();
|
||||||
await loadAllComponents();
|
await loadAllComponents();
|
||||||
renderTab();
|
renderTab();
|
||||||
updateCartUI();
|
updateCartUI();
|
||||||
@@ -361,6 +372,44 @@ function updateServerCount() {
|
|||||||
triggerAutoSave();
|
triggerAutoSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadActivePricelists() {
|
||||||
|
const select = document.getElementById('pricelist-select');
|
||||||
|
if (!select) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/pricelists?active_only=true&per_page=200');
|
||||||
|
const data = await resp.json();
|
||||||
|
const pricelists = data.pricelists || [];
|
||||||
|
|
||||||
|
if (pricelists.length === 0) {
|
||||||
|
select.innerHTML = '<option value="">Нет активных прайслистов</option>';
|
||||||
|
selectedPricelistId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
select.innerHTML = pricelists.map(pl => {
|
||||||
|
return `<option value="${pl.id}">${pl.version}</option>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const existing = selectedPricelistId && pricelists.some(pl => Number(pl.id) === Number(selectedPricelistId));
|
||||||
|
if (existing) {
|
||||||
|
select.value = String(selectedPricelistId);
|
||||||
|
} else {
|
||||||
|
selectedPricelistId = Number(pricelists[0].id);
|
||||||
|
select.value = String(selectedPricelistId);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
select.innerHTML = '<option value="">Ошибка загрузки</option>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePricelistSelection() {
|
||||||
|
const select = document.getElementById('pricelist-select');
|
||||||
|
const next = parseInt(select.value);
|
||||||
|
selectedPricelistId = Number.isFinite(next) && next > 0 ? next : null;
|
||||||
|
triggerAutoSave();
|
||||||
|
}
|
||||||
|
|
||||||
function getCategoryFromLotName(lotName) {
|
function getCategoryFromLotName(lotName) {
|
||||||
const parts = lotName.split('_');
|
const parts = lotName.split('_');
|
||||||
return parts[0] || '';
|
return parts[0] || '';
|
||||||
@@ -1133,7 +1182,8 @@ async function saveConfig(showNotification = true) {
|
|||||||
items: cart,
|
items: cart,
|
||||||
custom_price: customPrice,
|
custom_price: customPrice,
|
||||||
notes: '',
|
notes: '',
|
||||||
server_count: serverCountValue
|
server_count: serverCountValue,
|
||||||
|
pricelist_id: selectedPricelistId
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1327,6 +1377,17 @@ async function refreshPrices() {
|
|||||||
if (config.price_updated_at) {
|
if (config.price_updated_at) {
|
||||||
updatePriceUpdateDate(config.price_updated_at);
|
updatePriceUpdateDate(config.price_updated_at);
|
||||||
}
|
}
|
||||||
|
if (config.pricelist_id) {
|
||||||
|
selectedPricelistId = config.pricelist_id;
|
||||||
|
const select = document.getElementById('pricelist-select');
|
||||||
|
if (select) {
|
||||||
|
const hasOption = Array.from(select.options).some(opt => Number(opt.value) === Number(selectedPricelistId));
|
||||||
|
if (!hasOption) {
|
||||||
|
await loadActivePricelists();
|
||||||
|
}
|
||||||
|
select.value = String(selectedPricelistId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Re-render UI
|
// Re-render UI
|
||||||
renderTab();
|
renderTab();
|
||||||
|
|||||||
@@ -147,12 +147,15 @@
|
|||||||
let settings = [];
|
let settings = [];
|
||||||
const hasManualPrice = item.manual_price && item.manual_price > 0;
|
const hasManualPrice = item.manual_price && item.manual_price > 0;
|
||||||
const hasMeta = item.meta_prices && item.meta_prices.trim() !== '';
|
const hasMeta = item.meta_prices && item.meta_prices.trim() !== '';
|
||||||
|
const method = (item.price_method || '').toLowerCase();
|
||||||
|
|
||||||
// Method indicator
|
// Method indicator
|
||||||
if (hasManualPrice) {
|
if (hasManualPrice) {
|
||||||
settings.push('<span class="text-orange-600 font-medium">РУЧН</span>');
|
settings.push('<span class="text-orange-600 font-medium">РУЧН</span>');
|
||||||
} else if (item.price_method === 'average') {
|
} else if (method === 'average') {
|
||||||
settings.push('Сред');
|
settings.push('Сред');
|
||||||
|
} else if (method === 'weighted_median') {
|
||||||
|
settings.push('Взвеш. мед');
|
||||||
} else {
|
} else {
|
||||||
settings.push('Мед');
|
settings.push('Мед');
|
||||||
}
|
}
|
||||||
|
|||||||
476
web/templates/project_detail.html
Normal file
476
web/templates/project_detail.html
Normal 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" .}}
|
||||||
330
web/templates/projects.html
Normal file
330
web/templates/projects.html
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
{{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 = '';
|
||||||
|
let authorSearch = '';
|
||||||
|
let currentPage = 1;
|
||||||
|
let perPage = 10;
|
||||||
|
let sortField = 'created_at';
|
||||||
|
let sortDir = 'desc';
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text || '';
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMoney(v) {
|
||||||
|
return '$' + (v || 0).toLocaleString('en-US', {minimumFractionDigits: 2});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value) {
|
||||||
|
if (!value) return '—';
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return '—';
|
||||||
|
return date.toLocaleString('ru-RU', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSort(field) {
|
||||||
|
if (sortField === field) {
|
||||||
|
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
sortField = field;
|
||||||
|
sortDir = field === 'name' ? 'asc' : 'desc';
|
||||||
|
}
|
||||||
|
currentPage = 1;
|
||||||
|
loadProjects();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(value) {
|
||||||
|
status = value;
|
||||||
|
currentPage = 1;
|
||||||
|
document.getElementById('status-active-btn').className = value === 'active'
|
||||||
|
? 'px-4 py-2 text-sm font-medium bg-blue-600 text-white'
|
||||||
|
: 'px-4 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50';
|
||||||
|
document.getElementById('status-archived-btn').className = value === 'archived'
|
||||||
|
? 'px-4 py-2 text-sm font-medium bg-blue-600 text-white border-l border-gray-200'
|
||||||
|
: 'px-4 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200';
|
||||||
|
loadProjects();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProjects() {
|
||||||
|
const root = document.getElementById('projects-table');
|
||||||
|
root.innerHTML = '<div class="text-gray-500">Загрузка...</div>';
|
||||||
|
|
||||||
|
let rows = [];
|
||||||
|
let total = 0;
|
||||||
|
let totalPages = 0;
|
||||||
|
let page = currentPage;
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
status: status,
|
||||||
|
search: projectsSearch,
|
||||||
|
author: authorSearch,
|
||||||
|
page: String(currentPage),
|
||||||
|
per_page: String(perPage),
|
||||||
|
sort: sortField,
|
||||||
|
dir: sortDir
|
||||||
|
});
|
||||||
|
const resp = await fetch('/api/projects?' + params.toString());
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error('HTTP ' + resp.status);
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
rows = data.projects || [];
|
||||||
|
total = data.total || 0;
|
||||||
|
totalPages = data.total_pages || 0;
|
||||||
|
page = data.page || currentPage;
|
||||||
|
currentPage = page;
|
||||||
|
} catch (e) {
|
||||||
|
root.innerHTML = '<div class="text-red-600">Ошибка загрузки проектов: ' + escapeHtml(String(e.message || e)) + '</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<div class="overflow-x-auto"><table class="w-full">';
|
||||||
|
html += '<thead class="bg-gray-50">';
|
||||||
|
html += '<tr>';
|
||||||
|
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">';
|
||||||
|
html += '<button type="button" onclick="toggleSort(\'name\')" class="inline-flex items-center gap-1 hover:text-gray-700">Название проекта';
|
||||||
|
if (sortField === 'name') {
|
||||||
|
html += sortDir === 'asc' ? ' <span>↑</span>' : ' <span>↓</span>';
|
||||||
|
}
|
||||||
|
html += '</button></th>';
|
||||||
|
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>';
|
||||||
|
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">';
|
||||||
|
html += '<button type="button" onclick="toggleSort(\'created_at\')" class="inline-flex items-center gap-1 hover:text-gray-700">Создан';
|
||||||
|
if (sortField === 'created_at') {
|
||||||
|
html += sortDir === 'asc' ? ' <span>↑</span>' : ' <span>↓</span>';
|
||||||
|
}
|
||||||
|
html += '</button></th>';
|
||||||
|
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Кол-во квот</th>';
|
||||||
|
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Сумма</th>';
|
||||||
|
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
|
||||||
|
html += '</tr>';
|
||||||
|
html += '<tr>';
|
||||||
|
html += '<th class="px-4 py-2"></th>';
|
||||||
|
html += '<th class="px-4 py-2"><input id="projects-author-filter" type="text" value="' + escapeHtml(authorSearch) + '" placeholder="Фильтр автора" class="w-full px-2 py-1 border rounded text-xs focus:ring-1 focus:ring-blue-500 focus:border-blue-500"></th>';
|
||||||
|
html += '<th class="px-4 py-2"></th>';
|
||||||
|
html += '<th class="px-4 py-2"></th>';
|
||||||
|
html += '<th class="px-4 py-2"></th>';
|
||||||
|
html += '<th class="px-4 py-2"></th>';
|
||||||
|
html += '</tr>';
|
||||||
|
html += '</thead><tbody class="divide-y">';
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
html += '<tr><td colspan="6" class="px-4 py-6 text-sm text-gray-500 text-center">Проектов нет</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.forEach(p => {
|
||||||
|
html += '<tr class="hover:bg-gray-50">';
|
||||||
|
html += '<td class="px-4 py-3 text-sm font-medium"><a class="text-blue-600 hover:underline" href="/projects/' + p.uuid + '">' + escapeHtml(p.name) + '</a></td>';
|
||||||
|
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(p.owner_username || '—') + '</td>';
|
||||||
|
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(formatDateTime(p.created_at)) + '</td>';
|
||||||
|
html += '<td class="px-4 py-3 text-sm text-right text-gray-700">' + (p.config_count || 0) + '</td>';
|
||||||
|
html += '<td class="px-4 py-3 text-sm text-right text-gray-700">' + formatMoney(p.total) + '</td>';
|
||||||
|
html += '<td class="px-4 py-3 text-sm text-right"><div class="inline-flex items-center gap-2">';
|
||||||
|
|
||||||
|
if (p.is_active) {
|
||||||
|
html += '<button onclick="copyProject(\'' + p.uuid + '\', \'' + escapeHtml(p.name).replace(/'/g, "\\'") + '\')" class="text-green-700 hover:text-green-900" title="Копировать">';
|
||||||
|
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>';
|
||||||
|
html += '</button>';
|
||||||
|
|
||||||
|
html += '<button onclick="renameProject(\'' + p.uuid + '\', \'' + escapeHtml(p.name).replace(/'/g, "\\'") + '\')" class="text-blue-700 hover:text-blue-900" title="Переименовать">';
|
||||||
|
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>';
|
||||||
|
html += '</button>';
|
||||||
|
|
||||||
|
html += '<button onclick="archiveProject(\'' + p.uuid + '\')" class="text-red-700 hover:text-red-900" title="Удалить (в архив)">';
|
||||||
|
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>';
|
||||||
|
html += '</button>';
|
||||||
|
|
||||||
|
html += '<button onclick="addConfigToProject(\'' + p.uuid + '\')" class="text-indigo-700 hover:text-indigo-900" title="Добавить квоту">';
|
||||||
|
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>';
|
||||||
|
html += '</button>';
|
||||||
|
} else {
|
||||||
|
html += '<button onclick="reactivateProject(\'' + p.uuid + '\')" class="text-emerald-700 hover:text-emerald-900" title="Восстановить">';
|
||||||
|
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>';
|
||||||
|
html += '</button>';
|
||||||
|
}
|
||||||
|
html += '</div></td>';
|
||||||
|
html += '</tr>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
|
||||||
|
if (totalPages > 1) {
|
||||||
|
html += '<div class="flex items-center justify-between mt-4 pt-4 border-t">';
|
||||||
|
html += '<div class="text-sm text-gray-600">Показано ' + rows.length + ' из ' + total + '</div>';
|
||||||
|
html += '<div class="inline-flex items-center gap-1">';
|
||||||
|
html += '<button type="button" onclick="goToPage(' + (page - 1) + ')" ' + (page <= 1 ? 'disabled' : '') + ' class="px-3 py-1 text-sm border rounded ' + (page <= 1 ? 'text-gray-300 border-gray-200 cursor-not-allowed' : 'text-gray-700 hover:bg-gray-50') + '">←</button>';
|
||||||
|
const startPage = Math.max(1, page - 2);
|
||||||
|
const endPage = Math.min(totalPages, page + 2);
|
||||||
|
for (let i = startPage; i <= endPage; i++) {
|
||||||
|
html += '<button type="button" onclick="goToPage(' + i + ')" class="px-3 py-1 text-sm border rounded ' + (i === page ? 'bg-blue-600 text-white border-blue-600' : 'text-gray-700 border-gray-300 hover:bg-gray-50') + '">' + i + '</button>';
|
||||||
|
}
|
||||||
|
html += '<button type="button" onclick="goToPage(' + (page + 1) + ')" ' + (page >= totalPages ? 'disabled' : '') + ' class="px-3 py-1 text-sm border rounded ' + (page >= totalPages ? 'text-gray-300 border-gray-200 cursor-not-allowed' : 'text-gray-700 hover:bg-gray-50') + '">→</button>';
|
||||||
|
html += '</div>';
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
root.innerHTML = html;
|
||||||
|
|
||||||
|
const authorInput = document.getElementById('projects-author-filter');
|
||||||
|
if (authorInput) {
|
||||||
|
authorInput.addEventListener('input', function(e) {
|
||||||
|
authorSearch = (e.target.value || '').trim();
|
||||||
|
currentPage = 1;
|
||||||
|
loadProjects();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToPage(page) {
|
||||||
|
if (page < 1) return;
|
||||||
|
currentPage = page;
|
||||||
|
loadProjects();
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
currentPage = 1;
|
||||||
|
loadProjects();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{template "base" .}}
|
||||||
@@ -158,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');
|
||||||
@@ -176,9 +186,8 @@
|
|||||||
|
|
||||||
// Check if restart is required
|
// Check if restart is required
|
||||||
if (data.restart_required) {
|
if (data.restart_required) {
|
||||||
// In normal mode, restart must be done manually
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
showStatus('⚠️ Пожалуйста, перезапустите приложение вручную для применения изменений', 'warning');
|
requestRestartAndWait();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
} else {
|
} else {
|
||||||
// In setup mode, auto-restart is happening
|
// In setup mode, auto-restart is happening
|
||||||
|
|||||||
Reference in New Issue
Block a user