28 Commits

Author SHA1 Message Date
7523a7d887 Remove admin pricing stack and prepare v1.0.4 release 2026-02-07 21:23:23 +03:00
95b5f8bf65 refactor lot matching into shared module 2026-02-07 06:22:56 +03:00
b629af9742 Implement warehouse/lot pricing updates and configurator performance fixes 2026-02-07 05:20:35 +03:00
72ff842f5d Fix stock import UI bugs: dead code, fragile data attr, double-click, silent duplicates
- Remove unused stockMappingsCache variable (dead code after selectStockMappingRow removal)
- Move data-description from SVG to button element for reliable access
- Add disabled guard on bulk add/ignore buttons to prevent duplicate requests
- Return explicit error in UpsertIgnoreRule when rule already exists

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 23:30:01 +03:00
Mikhail Chusavitin
5f2969a85a Refine stock import UX with suggestions, ignore rules, and inline mapping controls 2026-02-06 19:58:42 +03:00
Mikhail Chusavitin
eb8ac34d83 Fix stock mappings JSON fields and enable row selection for editing 2026-02-06 19:39:39 +03:00
Mikhail Chusavitin
104a26d907 Add stock pricelist admin flow with mapping placeholders and warehouse details 2026-02-06 19:37:12 +03:00
Mikhail Chusavitin
b965c6bb95 WIP: save current pricing and pricelist changes 2026-02-06 19:07:22 +03:00
Mikhail Chusavitin
29035ddc5a configs: save pending template changes 2026-02-06 16:43:04 +03:00
Mikhail Chusavitin
2f0ac2f6d2 projects: add tracker_url and project create modal 2026-02-06 16:42:32 +03:00
Mikhail Chusavitin
8a8ea10dc2 Add tracker link on project detail page 2026-02-06 16:31:34 +03:00
Mikhail Chusavitin
51e2d1fc83 Fix local pricelist uniqueness and preserve config project on update 2026-02-06 16:00:23 +03:00
Mikhail Chusavitin
3d5ab63970 Make full sync push pending and pull projects/configurations 2026-02-06 15:25:07 +03:00
Mikhail Chusavitin
c02a7eac73 Prepare v1.0.3 release notes 2026-02-06 14:04:06 +03:00
Mikhail Chusavitin
651427e0dd Add projects table controls and sync status tab with app version 2026-02-06 14:02:21 +03:00
Mikhail Chusavitin
f665e9b08c sync: recover missing server config during update push 2026-02-06 13:41:01 +03:00
Mikhail Chusavitin
994eec53e7 Fix MySQL DSN escaping for setup passwords and clarify DB user setup 2026-02-06 13:27:57 +03:00
Mikhail Chusavitin
2f3c20fea6 update stale files list 2026-02-06 13:03:59 +03:00
Mikhail Chusavitin
80ec7bc6b8 Apply remaining pricelist and local-first updates 2026-02-06 13:01:40 +03:00
Mikhail Chusavitin
8e5c4f5a7c Use admin price-refresh logic for pricelist recalculation 2026-02-06 13:00:27 +03:00
Mikhail Chusavitin
1744e6a3b8 fix: skip startup sql migrations when not needed or no permissions 2026-02-06 11:56:55 +03:00
Mikhail Chusavitin
726dccb07c feat: add projects flow and consolidate default project handling 2026-02-06 11:39:12 +03:00
Mikhail Chusavitin
38d7332a38 Update pricelist repository, service, and tests 2026-02-06 10:14:24 +03:00
Mikhail Chusavitin
c0beed021c Enforce pricelist write checks and auto-restart on DB settings change 2026-02-05 15:44:54 +03:00
Mikhail Chusavitin
08b95c293c Purge orphan sync queue entries before push 2026-02-05 15:17:06 +03:00
Mikhail Chusavitin
c418d6cfc3 Handle stale configuration sync events when local row is missing 2026-02-05 15:11:43 +03:00
Mikhail Chusavitin
548a256d04 Drop qt_users dependency for configs and track app version 2026-02-05 15:07:23 +03:00
Mikhail Chusavitin
77c00de97a Добавил шаблон для создания пользователя в БД 2026-02-05 10:55:02 +03:00
80 changed files with 8582 additions and 3760 deletions

19
.gitignore vendored
View File

@@ -16,6 +16,25 @@ config.yaml
# Local Go build cache used in sandboxed runs # Local Go build cache used in sandboxed runs
.gocache/ .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

194
CLAUDE.md
View File

@@ -1,163 +1,71 @@
# QuoteForge - Claude Code Instructions # QuoteForge - Claude Code Instructions
## Overview ## Overview
Корпоративный конфигуратор серверов и формирование КП. MariaDB (RFQ_LOG) + SQLite для оффлайн. Корпоративный конфигуратор серверов с offline-first архитектурой.
Приложение работает через локальную SQLite базу, синхронизация с MariaDB выполняется фоново.
## Development Phases ## Product Scope
- Конфигуратор компонентов и расчёт КП
- Проекты и конфигурации
- Read-only просмотр прайслистов из локального кэша
- Sync (pull компонентов/прайслистов, push локальных изменений)
### Phase 1: Pricelists in MariaDB ✅ DONE Из области исключены:
### Phase 2: Local SQLite Database ✅ DONE - admin pricing UI/API
- stock import
- alerts
- cron/importer утилиты
### Phase 2.5: Full Offline Mode 🔶 IN PROGRESS ## Architecture
**Local-first architecture:** приложение ВСЕГДА работает с SQLite, MariaDB только для синхронизации. - Local-first: чтение и запись происходят в SQLite
- MariaDB используется как сервер синхронизации
- Background worker: периодический sync push+pull
**Принцип работы:** ## Key SQLite Data
- ВСЕ операции (CRUD) выполняются в SQLite - `connection_settings`
- При создании конфигурации: - `local_components`
1. Если online → проверить новые прайслисты на сервере → скачать если есть - `local_pricelists`, `local_pricelist_items`
2. Далее работаем с local_pricelists (и online, и offline одинаково) - `local_configurations`
- Background sync: push pending_changes → pull updates - `local_projects`
- `pending_changes`
**DONE:**
- ✅ Sync queue table (pending_changes) - `internal/localdb/models.go`
- ✅ Model converters: MariaDB ↔ SQLite - `internal/localdb/converters.go`
- ✅ LocalConfigurationService: все CRUD через SQLite - `internal/services/local_configuration.go`
- ✅ Pre-create pricelist check: `SyncPricelistsIfNeeded()` - `internal/services/sync/service.go`
- ✅ Push pending changes: `PushPendingChanges()` - sync service + handlers
- ✅ Sync API endpoints: `/api/sync/push`, `/pending/count`, `/pending`
- ✅ Integrate LocalConfigurationService in main.go (replace ConfigurationService)
- ✅ Add routes for new sync endpoints (`/api/sync/push`, `/pending/count`, `/pending`)
- ✅ ConfigurationGetter interface for handler compatibility
- ✅ Background sync worker: auto-sync every 5min (push + pull) - `internal/services/sync/worker.go`
- ✅ UI: sync status indicator (pending badge + sync button + offline/online dot) - `web/templates/partials/sync_status.html`
- ✅ RefreshPrices for local mode:
- `RefreshPrices()` / `RefreshPricesNoAuth()` в `local_configuration.go`
- Берёт цены из `local_components.current_price`
- Graceful degradation при отсутствии компонента
- Добавлено поле `price_updated_at` в `LocalConfiguration` (models.go:72)
- Обновлены converters для PriceUpdatedAt
- UI кнопка "Пересчитать цену" работает offline/online
- ✅ Fixed sync bugs:
- Duplicate entry error при update конфигураций (`sync/service.go:334-365`)
- pushConfigurationUpdate теперь проверяет наличие server_id перед update
- Если нет ID → получает из LocalConfiguration.ServerID или ищет на сервере
- Fixed setup.go: `settings.Password``settings.PasswordEncrypted`
**TODO:**
- ❌ Conflict resolution (Phase 4, last-write-wins default)
### UI Improvements ✅ MOSTLY DONE
**1. Sync UI + pricelist badge: ✅ DONE**
-`sync_status.html`: SVG иконки Online/Offline (кликабельные → открывают модал)
- ✅ Кнопка sync → иконка circular arrows (только full sync)
- ✅ Модальное окно "Статус системы" в `base.html` (info о БД, ошибки синхронизации)
-`configs.html`: badge с версией активного прайслиста
- ✅ Загрузка через `/api/pricelists/latest` при DOMContentLoaded
- ✅ Удалён dropdown с Push changes (упрощение UI)
**2. Прайслисты → вкладка в "Администратор цен": ✅ DONE**
-`base.html`: убрана ссылка "Прайслисты" из навигации
-`admin_pricing.html`: добавлена вкладка "Прайслисты"
- ✅ Логика перенесена из `pricelists.html` (table, create modal, CRUD)
- ✅ Route `/pricelists` → редирект на `/admin/pricing?tab=pricelists`
- ✅ Поддержка URL param `?tab=pricelists`
**3. Модал "Настройка цены" - кол-во котировок с учётом периода: ❌ TODO**
- Текущее: показывает только общее кол-во котировок
- Новое: показывать `N (всего: M)` где N - за выбранный период, M - всего
-`admin_pricing.html`: обновить `#modal-quote-count`
-`admin_pricing_handler.go`: в `/api/admin/pricing/preview` возвращать `quote_count_period` + `quote_count_total`
**4. Страница настроек: ❌ ОТЛОЖЕНО**
- Перенесено в Phase 3 (после основных UI улучшений)
### Phase 3: Projects and Specifications
- qt_projects, qt_specifications tables (MariaDB)
- Replace qt_configurations → Project/Specification hierarchy
- Fields: opty, customer_requirement, variant, qty, rev
- Local projects/specs with server sync
### Phase 4: Price Versioning
- Bind specifications to pricelist versions
- Price diff comparison
- Auto-cleanup expired pricelists (>1 year, usage_count=0)
## Tech Stack
Go 1.22+ | Gin | GORM | MariaDB 11 | SQLite (glebarez/sqlite) | htmx + Tailwind CDN | excelize
## Key Tables
### READ-ONLY (external systems)
- `lot` (lot_name PK, lot_description)
- `lot_log` (lot, supplier, date, price, quality, comments)
- `supplier` (supplier_name PK)
### MariaDB (qt_* prefix)
- `qt_lot_metadata` - component prices, methods, popularity
- `qt_categories` - category codes and names
- `qt_pricelists` - version snapshots (YYYY-MM-DD-NNN format)
- `qt_pricelist_items` - prices per pricelist
- `qt_projects` - uuid, opty, customer_requirement, name (Phase 3)
- `qt_specifications` - project_id, pricelist_id, variant, rev, qty, items JSON (Phase 3)
### SQLite (data/quoteforge.db)
- `connection_settings` - encrypted DB credentials (PasswordEncrypted field)
- `local_pricelists/items` - cached from server
- `local_components` - lot cache for offline search (with current_price)
- `local_configurations` - UUID, items, price_updated_at, sync_status (pending/synced/conflict), server_id
- `local_projects/specifications` - Phase 3
- `pending_changes` - sync queue (entity_type, uuid, op, payload, created_at, attempts, last_error)
## Business Logic
**Part number parsing:** `CPU_AMD_9654` → category=`CPU`, model=`AMD_9654`
**Price methods:** manual | median | average | weighted_median
**Price freshness:** fresh (<30d, ≥3 quotes) | normal (<60d) | stale (<90d) | critical
**Pricelist version:** `YYYY-MM-DD-NNN` (e.g., `2024-01-31-001`)
## API Endpoints ## API Endpoints
| Group | Endpoints | | Group | Endpoints |
|-------|-----------| |-------|-----------|
| Setup | GET/POST /setup, POST /setup/test | | Setup | `GET /setup`, `POST /setup`, `POST /setup/test`, `GET /setup/status` |
| Components | GET /api/components, /api/categories | | Components | `GET /api/components`, `GET /api/components/:lot_name`, `GET /api/categories` |
| Pricelists | CRUD /api/pricelists, GET /latest, POST /compare | | Quote | `POST /api/quote/validate`, `POST /api/quote/calculate`, `POST /api/quote/price-levels` |
| Projects | CRUD /api/projects/:uuid (Phase 3) | | Pricelists (read-only) | `GET /api/pricelists`, `GET /api/pricelists/latest`, `GET /api/pricelists/:id`, `GET /api/pricelists/:id/items`, `GET /api/pricelists/:id/lots` |
| Specs | CRUD /api/specs/:uuid, POST /upgrade, GET /diff (Phase 3) | | Configs | CRUD + refresh/clone/reactivate/rename/project binding via `/api/configs/*` |
| Configs | POST /:uuid/refresh-prices (обновить цены из local_components) | | Projects | CRUD + nested configs via `/api/projects/*` |
| Sync | GET /status, POST /components, /pricelists, /push, /pull, /resolve-conflict | | Sync | `GET /api/sync/status`, `GET /api/sync/readiness`, `GET /api/sync/info`, `GET /api/sync/users-status`, `POST /api/sync/components`, `POST /api/sync/pricelists`, `POST /api/sync/all`, `POST /api/sync/push`, `GET /api/sync/pending`, `GET /api/sync/pending/count` |
| Export | GET /api/specs/:uuid/export, /api/projects/:uuid/export | | Export | `POST /api/export/csv` |
## Web Routes
- `/configs`
- `/configurator`
- `/projects`
- `/projects/:uuid`
- `/pricelists`
- `/pricelists/:id`
- `/setup`
## Commands ## Commands
```bash ```bash
# Development # Development
go run ./cmd/qfs # Dev server go run ./cmd/qfs
make run # Dev server (via Makefile) make run
# Production build # Build
make build-release # Optimized build with version (recommended) make build-release
VERSION=$(git describe --tags --always --dirty) CGO_ENABLED=0 go build -o bin/qfs ./cmd/qfs
CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=$VERSION" -o bin/qfs ./cmd/qfs
# Cron jobs # Verification
go run ./cmd/cron -job=cleanup-pricelists # Remove old unused pricelists go build ./cmd/qfs
go run ./cmd/cron -job=update-prices # Recalculate all prices go vet ./...
go run ./cmd/cron -job=update-popularity # Update popularity scores
# Check version
./bin/qfs -version
``` ```
## Code Style ## Code Style
- gofmt, structured logging (slog), wrap errors with context - gofmt
- snake_case files, PascalCase types - structured logging (`slog`)
- RBAC disabled: DB username = user_id via `models.EnsureDBUser()` - explicit error wrapping with context
## UI Guidelines
- htmx (hx-get/post/target/swap), Tailwind CDN
- Freshness colors: green (fresh) → yellow → orange → red (critical)
- Sync status + offline indicator in header

102
README.md
View File

@@ -2,7 +2,8 @@
**Server Configuration & Quotation Tool** **Server Configuration & Quotation Tool**
QuoteForge — корпоративный инструмент для конфигурирования серверов и формирования коммерческих предложений (КП). Приложение интегрируется с существующей базой данных RFQ_LOG. QuoteForge — корпоративный инструмент для конфигурирования серверов и формирования коммерческих предложений (КП).
Приложение работает в strict local-first режиме: пользовательские операции выполняются через локальную SQLite, MariaDB используется для синхронизации и серверного администрирования прайслистов.
![Go Version](https://img.shields.io/badge/Go-1.22+-00ADD8?style=flat&logo=go) ![Go Version](https://img.shields.io/badge/Go-1.22+-00ADD8?style=flat&logo=go)
![License](https://img.shields.io/badge/License-Proprietary-red) ![License](https://img.shields.io/badge/License-Proprietary-red)
@@ -16,6 +17,8 @@ QuoteForge — корпоративный инструмент для конфи
- 💰 **Автоматический расчёт цен** — актуальные цены на основе истории закупок - 💰 **Автоматический расчёт цен** — актуальные цены на основе истории закупок
- 📊 **Экспорт в CSV/XLSX** — готовые спецификации для клиентов - 📊 **Экспорт в CSV/XLSX** — готовые спецификации для клиентов
- 💾 **Сохранение конфигураций** — история и шаблоны для повторного использования - 💾 **Сохранение конфигураций** — история и шаблоны для повторного использования
- 🔌 **Полная офлайн-работа** — можно продолжать работу без сети и синхронизировать позже
- 🛡️ **Защищенная синхронизация** — sync блокируется preflight-проверкой, если локальная схема не готова
### Для ценовых администраторов ### Для ценовых администраторов
- 📈 **Умный расчёт цен** — медиана, взвешенная медиана, среднее - 📈 **Умный расчёт цен** — медиана, взвешенная медиана, среднее
@@ -35,7 +38,7 @@ QuoteForge — корпоративный инструмент для конфи
- **Backend:** Go 1.22+, Gin, GORM - **Backend:** Go 1.22+, Gin, GORM
- **Frontend:** HTML, Tailwind CSS, htmx - **Frontend:** HTML, Tailwind CSS, htmx
- **Database:** MariaDB 11+ - **Database:** SQLite (runtime/local-first), MariaDB 11+ (sync + server admin)
- **Export:** excelize (XLSX), encoding/csv - **Export:** excelize (XLSX), encoding/csv
## Требования ## Требования
@@ -85,6 +88,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 'DB_PASSWORD_PLACEHOLDER';
-- 2) Если пользователь уже существовал, принудительно обновить пароль
ALTER USER 'quote_user'@'%' IDENTIFIED BY 'DB_PASSWORD_PLACEHOLDER';
-- 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 'DB_PASSWORD_PLACEHOLDER';
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
@@ -131,6 +210,18 @@ make help # Показать все команды
Можно переопределить путь через `-localdb` или переменную окружения `QFS_DB_PATH`. Можно переопределить путь через `-localdb` или переменную окружения `QFS_DB_PATH`.
#### Sync readiness guard
Перед `push/pull` выполняется preflight-проверка:
- доступен ли сервер (MariaDB);
- можно ли проверить и применить централизованные миграции локальной БД;
- подходит ли версия приложения под `min_app_version` миграций.
Если проверка не пройдена:
- локальная работа (CRUD) продолжается;
- sync API возвращает `423 Locked` с `reason_code` и `reason_text`;
- в UI показывается красный индикатор и причина блокировки в модалке синхронизации.
### Версионность конфигураций (local-first) ### Версионность конфигураций (local-first)
Для `local_configurations` используется append-only versioning через полные snapshot-версии: Для `local_configurations` используется append-only versioning через полные snapshot-версии:
@@ -225,6 +316,13 @@ GET /api/configs/:uuid/versions # Список версий конф
GET /api/configs/:uuid/versions/:version # Получить конкретную версию GET /api/configs/:uuid/versions/:version # Получить конкретную версию
POST /api/configs/:uuid/rollback # Rollback на указанную версию POST /api/configs/:uuid/rollback # Rollback на указанную версию
POST /api/configs/:uuid/reactivate # Вернуть архивную конфигурацию в активные POST /api/configs/:uuid/reactivate # Вернуть архивную конфигурацию в активные
GET /api/sync/readiness # Статус readiness guard (ready|blocked|unknown)
GET /api/sync/status # Сводный статус синхронизации
GET /api/sync/info # Данные для модалки синхронизации
POST /api/sync/push # Push pending changes (423, если blocked)
POST /api/sync/all # Full sync push+pull (423, если blocked)
POST /api/sync/components # Pull components (423, если blocked)
POST /api/sync/pricelists # Pull pricelists (423, если blocked)
``` ```
#### Sync payload для versioning #### Sync payload для versioning

View File

@@ -1,84 +0,0 @@
package main
import (
"flag"
"log"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services/alerts"
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func main() {
configPath := flag.String("config", "config.yaml", "path to config file")
cronJob := flag.String("job", "", "type of cron job to run (alerts, update-prices)")
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 to database: %v", err)
}
// Ensure tables exist
if err := models.Migrate(db); err != nil {
log.Fatalf("Migration failed: %v", err)
}
// Initialize repositories
statsRepo := repository.NewStatsRepository(db)
alertRepo := repository.NewAlertRepository(db)
componentRepo := repository.NewComponentRepository(db)
priceRepo := repository.NewPriceRepository(db)
// Initialize services
alertService := alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
pricingService := pricing.NewService(componentRepo, priceRepo, cfg.Pricing)
switch *cronJob {
case "alerts":
log.Println("Running alerts check...")
if err := alertService.CheckAndGenerateAlerts(); err != nil {
log.Printf("Error running alerts check: %v", err)
} else {
log.Println("Alerts check completed successfully")
}
case "update-prices":
log.Println("Recalculating all prices...")
updated, errors := pricingService.RecalculateAllPrices()
log.Printf("Prices recalculated: %d updated, %d errors", updated, errors)
case "reset-counters":
log.Println("Resetting usage counters...")
if err := statsRepo.ResetWeeklyCounters(); err != nil {
log.Printf("Error resetting weekly counters: %v", err)
}
if err := statsRepo.ResetMonthlyCounters(); err != nil {
log.Printf("Error resetting monthly counters: %v", err)
}
log.Println("Usage counters reset completed")
case "update-popularity":
log.Println("Updating popularity scores...")
if err := statsRepo.UpdatePopularityScores(); err != nil {
log.Printf("Error updating popularity scores: %v", err)
} else {
log.Println("Popularity scores updated successfully")
}
default:
log.Println("No valid cron job specified. Available jobs:")
log.Println(" - alerts: Check and generate alerts")
log.Println(" - update-prices: Recalculate all prices")
log.Println(" - reset-counters: Reset usage counters")
log.Println(" - update-popularity: Update popularity scores")
}
}

View File

@@ -1,160 +0,0 @@
package main
import (
"flag"
"log"
"strings"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func main() {
configPath := flag.String("config", "config.yaml", "path to config file")
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 to database: %v", err)
}
log.Println("Connected to database")
// Ensure tables exist
if err := models.Migrate(db); err != nil {
log.Fatalf("Migration failed: %v", err)
}
if err := models.SeedCategories(db); err != nil {
log.Fatalf("Seeding categories failed: %v", err)
}
// Load categories for lookup
var categories []models.Category
db.Find(&categories)
categoryMap := make(map[string]uint)
for _, c := range categories {
categoryMap[c.Code] = c.ID
}
log.Printf("Loaded %d categories", len(categories))
// Get all lots
var lots []models.Lot
if err := db.Find(&lots).Error; err != nil {
log.Fatalf("Failed to load lots: %v", err)
}
log.Printf("Found %d lots to import", len(lots))
// Import each lot
var imported, skipped, updated int
for _, lot := range lots {
category, model := ParsePartNumber(lot.LotName)
var categoryID *uint
if id, ok := categoryMap[category]; ok && id > 0 {
categoryID = &id
} else {
// Try to find by prefix match
for code, id := range categoryMap {
if strings.HasPrefix(category, code) {
categoryID = &id
break
}
}
}
// Check if already exists
var existing models.LotMetadata
result := db.Where("lot_name = ?", lot.LotName).First(&existing)
if result.Error == gorm.ErrRecordNotFound {
// Check if there are prices in the last 90 days
var recentPriceCount int64
db.Model(&models.LotLog{}).
Where("lot = ? AND date >= DATE_SUB(NOW(), INTERVAL 90 DAY)", lot.LotName).
Count(&recentPriceCount)
// Default to 90 days, but use "all time" (0) if no recent prices
periodDays := 90
if recentPriceCount == 0 {
periodDays = 0
}
// Create new
metadata := models.LotMetadata{
LotName: lot.LotName,
CategoryID: categoryID,
Model: model,
PricePeriodDays: periodDays,
}
if err := db.Create(&metadata).Error; err != nil {
log.Printf("Failed to create metadata for %s: %v", lot.LotName, err)
continue
}
imported++
} else if result.Error == nil {
// Update if needed
needsUpdate := false
if existing.Model == "" {
existing.Model = model
needsUpdate = true
}
if existing.CategoryID == nil {
existing.CategoryID = categoryID
needsUpdate = true
}
// Check if using default period (90 days) but no recent prices
if existing.PricePeriodDays == 90 {
var recentPriceCount int64
db.Model(&models.LotLog{}).
Where("lot = ? AND date >= DATE_SUB(NOW(), INTERVAL 90 DAY)", lot.LotName).
Count(&recentPriceCount)
if recentPriceCount == 0 {
existing.PricePeriodDays = 0
needsUpdate = true
}
}
if needsUpdate {
db.Save(&existing)
updated++
} else {
skipped++
}
}
}
log.Printf("Import complete: %d imported, %d updated, %d skipped", imported, updated, skipped)
// Show final counts
var metadataCount int64
db.Model(&models.LotMetadata{}).Count(&metadataCount)
log.Printf("Total metadata records: %d", metadataCount)
}
// ParsePartNumber extracts category and model from lot_name
// Examples:
// "CPU_AMD_9654" → category="CPU", model="AMD_9654"
// "MB_INTEL_4.Sapphire_2S" → category="MB", model="INTEL_4.Sapphire_2S"
func ParsePartNumber(lotName string) (category, model string) {
parts := strings.SplitN(lotName, "_", 2)
if len(parts) >= 1 {
category = parts[0]
}
if len(parts) >= 2 {
model = parts[1]
}
return
}

View File

@@ -66,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)
} }
@@ -78,9 +78,6 @@ func main() {
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 := c.OwnerUsername userName := c.OwnerUsername
if userName == "" && c.User != nil {
userName = c.User.Username
}
if userName == "" { if userName == "" {
userName = "unknown" userName = "unknown"
} }
@@ -120,6 +117,7 @@ func main() {
localConfig := &localdb.LocalConfiguration{ localConfig := &localdb.LocalConfiguration{
UUID: c.UUID, UUID: c.UUID,
ServerID: &c.ID, ServerID: &c.ID,
ProjectUUID: c.ProjectUUID,
Name: c.Name, Name: c.Name,
Items: localItems, Items: localItems,
TotalPrice: c.TotalPrice, TotalPrice: c.TotalPrice,
@@ -131,14 +129,10 @@ func main() {
UpdatedAt: now, UpdatedAt: now,
SyncedAt: &now, SyncedAt: &now,
SyncStatus: "synced", SyncStatus: "synced",
OriginalUserID: c.UserID, OriginalUserID: derefUint(c.UserID),
OriginalUsername: c.OwnerUsername, OriginalUsername: c.OwnerUsername,
} }
if localConfig.OriginalUsername == "" && c.User != nil {
localConfig.OriginalUsername = c.User.Username
}
if err := local.SaveConfiguration(localConfig); err != nil { if err := local.SaveConfiguration(localConfig); err != nil {
log.Printf(" ERROR: %s - %v", c.Name, err) log.Printf(" ERROR: %s - %v", c.Name, err)
errors++ errors++
@@ -173,3 +167,10 @@ func main() {
fmt.Println("\nDone! You can now run the server with: go run ./cmd/server") fmt.Println("\nDone! You can now run the server with: go run ./cmd/server")
} }
func derefUint(v *uint) uint {
if v == nil {
return 0
}
return *v
}

View File

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

View File

@@ -7,17 +7,22 @@ import (
"fmt" "fmt"
"io/fs" "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"
syncpkg "sync"
"syscall" "syscall"
"time" "time"
qfassets "git.mchus.pro/mchus/quoteforge" 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/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"
@@ -27,9 +32,6 @@ import (
"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" "git.mchus.pro/mchus/quoteforge/internal/services"
"git.mchus.pro/mchus/quoteforge/internal/services/alerts"
"git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
"git.mchus.pro/mchus/quoteforge/internal/services/sync" "git.mchus.pro/mchus/quoteforge/internal/services/sync"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/driver/mysql" "gorm.io/driver/mysql"
@@ -40,6 +42,9 @@ import (
// 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
const onDemandPullCooldown = 30 * time.Second
func main() { func main() {
configPath := flag.String("config", "", "path to config file (default: user state dir or QFS_CONFIG_PATH)") 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)") localDBPath := flag.String("localdb", "", "path to local SQLite database (default: user state dir or QFS_DB_PATH)")
@@ -55,6 +60,7 @@ func main() {
exePath, _ := os.Executable() exePath, _ := os.Executable()
slog.Info("starting qfs", "version", Version, "executable", exePath) slog.Info("starting qfs", "version", Version, "executable", exePath)
appmeta.SetVersion(Version)
resolvedConfigPath, err := appstate.ResolveConfigPath(*configPath) resolvedConfigPath, err := appstate.ResolveConfigPath(*configPath)
if err != nil { if err != nil {
@@ -162,18 +168,58 @@ 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, dbUser) 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)
} }
if readiness, readinessErr := syncService.GetReadiness(); readinessErr != nil {
slog.Warn("sync readiness check failed on startup", "error", readinessErr)
} else if readiness != nil && readiness.Blocked {
slog.Warn("sync readiness blocked on startup",
"reason_code", readiness.ReasonCode,
"reason_text", readiness.ReasonText,
)
}
// Start background sync worker (will auto-skip when offline) // Start background sync worker (will auto-skip when offline)
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{
@@ -205,9 +251,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()
@@ -222,6 +274,10 @@ func main() {
} }
slog.Info("server stopped") slog.Info("server stopped")
if shouldRestart {
restartProcess()
}
} }
func setConfigDefaults(cfg *config.Config) { func setConfigDefaults(cfg *config.Config) {
@@ -392,14 +448,12 @@ 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, dbUsername string) (*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
var componentRepo *repository.ComponentRepository var componentRepo *repository.ComponentRepository
var categoryRepo *repository.CategoryRepository var categoryRepo *repository.CategoryRepository
var priceRepo *repository.PriceRepository
var alertRepo *repository.AlertRepository
var statsRepo *repository.StatsRepository var statsRepo *repository.StatsRepository
var pricelistRepo *repository.PricelistRepository var pricelistRepo *repository.PricelistRepository
@@ -407,8 +461,6 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if mariaDB != nil { if mariaDB != nil {
componentRepo = repository.NewComponentRepository(mariaDB) componentRepo = repository.NewComponentRepository(mariaDB)
categoryRepo = repository.NewCategoryRepository(mariaDB) categoryRepo = repository.NewCategoryRepository(mariaDB)
priceRepo = repository.NewPriceRepository(mariaDB)
alertRepo = repository.NewAlertRepository(mariaDB)
statsRepo = repository.NewStatsRepository(mariaDB) statsRepo = repository.NewStatsRepository(mariaDB)
pricelistRepo = repository.NewPricelistRepository(mariaDB) pricelistRepo = repository.NewPricelistRepository(mariaDB)
} else { } else {
@@ -417,32 +469,24 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
} }
// Services // Services
var pricingService *pricing.Service
var componentService *services.ComponentService var componentService *services.ComponentService
var quoteService *services.QuoteService var quoteService *services.QuoteService
var exportService *services.ExportService var exportService *services.ExportService
var alertService *alerts.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)
if mariaDB != nil { if mariaDB != nil {
pricingService = pricing.NewService(componentRepo, priceRepo, cfg.Pricing)
componentService = services.NewComponentService(componentRepo, categoryRepo, statsRepo) componentService = services.NewComponentService(componentRepo, categoryRepo, statsRepo)
quoteService = services.NewQuoteService(componentRepo, statsRepo, pricingService) quoteService = services.NewQuoteService(componentRepo, statsRepo, pricelistRepo, local, nil)
exportService = services.NewExportService(cfg.Export, categoryRepo) exportService = services.NewExportService(cfg.Export, categoryRepo)
alertService = alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
pricelistService = pricelist.NewService(mariaDB, pricelistRepo, componentRepo)
} 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)
componentService = services.NewComponentService(nil, nil, nil) componentService = services.NewComponentService(nil, nil, nil)
quoteService = services.NewQuoteService(nil, nil, pricingService) quoteService = services.NewQuoteService(nil, nil, nil, local, nil)
exportService = services.NewExportService(cfg.Export, nil) exportService = services.NewExportService(cfg.Export, nil)
alertService = alerts.NewService(nil, nil, nil, nil, cfg.Alerts, cfg.Pricing)
pricelistService = pricelist.NewService(nil, nil, nil)
} }
// isOnline function for local-first architecture // isOnline function for local-first architecture
@@ -451,8 +495,100 @@ 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)
}
}
type pullState struct {
mu syncpkg.Mutex
running bool
lastStarted time.Time
}
triggerPull := func(label string, state *pullState, pullFn func() error) {
state.mu.Lock()
if state.running {
state.mu.Unlock()
return
}
if !state.lastStarted.IsZero() && time.Since(state.lastStarted) < onDemandPullCooldown {
state.mu.Unlock()
return
}
state.running = true
state.lastStarted = time.Now()
state.mu.Unlock()
go func() {
defer func() {
state.mu.Lock()
state.running = false
state.mu.Unlock()
}()
if err := pullFn(); err != nil {
slog.Warn("on-demand pull failed", "scope", label, "error", err)
}
}()
}
var projectsPullState pullState
var configsPullState pullState
syncProjectsFromServer := func() error {
if !connMgr.IsOnline() {
return nil
}
if readiness, err := syncService.EnsureReadinessForSync(); err != nil {
slog.Warn("skipping project pull: sync readiness blocked",
"error", err,
"reason_code", readiness.ReasonCode,
"reason_text", readiness.ReasonText,
)
return nil
}
if _, err := syncService.ImportProjectsToLocal(); err != nil && !errors.Is(err, sync.ErrOffline) {
return err
}
return nil
}
syncConfigurationsFromServer := func() error {
if !connMgr.IsOnline() {
return nil
}
if readiness, err := syncService.EnsureReadinessForSync(); err != nil {
slog.Warn("skipping configuration pull: sync readiness blocked",
"error", err,
"reason_code", readiness.ReasonCode,
"reason_text", readiness.ReasonText,
)
return nil
}
_, err := configService.ImportFromServer()
if err != nil && !errors.Is(err, sync.ErrOffline) {
return err
}
return nil
}
// 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")
@@ -460,15 +596,14 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
componentHandler := handlers.NewComponentHandler(componentService, local) componentHandler := handlers.NewComponentHandler(componentService, local)
quoteHandler := handlers.NewQuoteHandler(quoteService) quoteHandler := handlers.NewQuoteHandler(quoteService)
exportHandler := handlers.NewExportHandler(exportService, configService, componentService) exportHandler := handlers.NewExportHandler(exportService, configService, componentService)
pricingHandler := handlers.NewPricingHandler(mariaDB, pricingService, alertService, componentRepo, priceRepo, statsRepo) pricelistHandler := handlers.NewPricelistHandler(local)
pricelistHandler := handlers.NewPricelistHandler(pricelistService, local) syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval)
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath)
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)
} }
@@ -517,28 +652,39 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
// DB status endpoint // DB status endpoint
router.GET("/api/db-status", func(c *gin.Context) { router.GET("/api/db-status", func(c *gin.Context) {
var lotCount, lotLogCount, metadataCount int64 var lotCount, lotLogCount, metadataCount int64
var dbOK bool = false var dbOK bool
var dbError string var dbError string
includeCounts := c.Query("include_counts") == "true"
// Check if connection exists (fast check, no reconnect attempt) // Fast status path: do not execute heavy COUNT queries unless requested.
status := connMgr.GetStatus() status := connMgr.GetStatus()
if status.IsConnected { dbOK = status.IsConnected
// Already connected, safe to use if !status.IsConnected {
if db, err := connMgr.GetDB(); err == nil && db != nil {
dbOK = true
db.Table("lot").Count(&lotCount)
db.Table("lot_log").Count(&lotLogCount)
db.Table("qt_lot_metadata").Count(&metadataCount)
}
} else {
// Not connected - don't try to reconnect on status check
// This prevents 3s timeout on every request
dbError = "Database not connected (offline mode)" dbError = "Database not connected (offline mode)"
if status.LastError != "" { if status.LastError != "" {
dbError = status.LastError dbError = status.LastError
} }
} }
// Optional diagnostics mode with server table counts.
if includeCounts && status.IsConnected {
if db, err := connMgr.GetDB(); err == nil && db != nil {
_ = db.Table("lot").Count(&lotCount)
_ = db.Table("lot_log").Count(&lotLogCount)
_ = db.Table("qt_lot_metadata").Count(&metadataCount)
} else if err != nil {
dbOK = false
dbError = err.Error()
} else {
dbOK = false
dbError = "Database not connected (offline mode)"
}
} else {
lotCount = 0
lotLogCount = 0
metadataCount = 0
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"connected": dbOK, "connected": dbOK,
"error": dbError, "error": dbError,
@@ -567,12 +713,10 @@ 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("/pricelists", func(c *gin.Context) { router.GET("/projects", webHandler.Projects)
// Redirect to admin/pricing with pricelists tab router.GET("/projects/:uuid", webHandler.ProjectDetail)
c.Redirect(http.StatusFound, "/admin/pricing?tab=pricelists") router.GET("/pricelists", webHandler.Pricelists)
})
router.GET("/pricelists/:id", webHandler.PricelistDetail) router.GET("/pricelists/:id", webHandler.PricelistDetail)
router.GET("/admin/pricing", webHandler.AdminPricing)
// htmx partials // htmx partials
partials := router.Group("/partials") partials := router.Group("/partials")
@@ -603,6 +747,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
{ {
quote.POST("/validate", quoteHandler.Validate) quote.POST("/validate", quoteHandler.Validate)
quote.POST("/calculate", quoteHandler.Calculate) quote.POST("/calculate", quoteHandler.Calculate)
quote.POST("/price-levels", quoteHandler.PriceLevels)
} }
// Export (public) // Export (public)
@@ -615,27 +760,28 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
pricelists := api.Group("/pricelists") pricelists := api.Group("/pricelists")
{ {
pricelists.GET("", pricelistHandler.List) pricelists.GET("", pricelistHandler.List)
pricelists.GET("/can-write", pricelistHandler.CanWrite)
pricelists.GET("/latest", pricelistHandler.GetLatest) pricelists.GET("/latest", pricelistHandler.GetLatest)
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.GET("/:id/lots", pricelistHandler.GetLotNames)
pricelists.DELETE("/:id", pricelistHandler.Delete)
} }
// Configurations (public - RBAC disabled) // Configurations (public - RBAC disabled)
configs := api.Group("/configs") configs := api.Group("/configs")
{ {
configs.GET("", func(c *gin.Context) { configs.GET("", func(c *gin.Context) {
triggerPull("configs", &configsPullState, 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") status := c.DefaultQuery("status", "active")
search := c.Query("search")
if status != "active" && status != "archived" && status != "all" { if status != "active" && status != "archived" && status != "all" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"})
return return
} }
cfgs, total, err := configService.ListAllWithStatus(page, perPage, status) 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
@@ -647,6 +793,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
"page": page, "page": page,
"per_page": perPage, "per_page": perPage,
"status": status, "status": status,
"search": search,
}) })
}) })
@@ -776,6 +923,32 @@ 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) { configs.GET("/:uuid/versions", func(c *gin.Context) {
uuid := c.Param("uuid") uuid := c.Param("uuid")
@@ -881,27 +1054,325 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
}) })
} }
// Pricing admin (public - RBAC disabled) projects := api.Group("/projects")
pricingAdmin := api.Group("/admin/pricing")
{ {
pricingAdmin.GET("/stats", pricingHandler.GetStats) projects.GET("", func(c *gin.Context) {
pricingAdmin.GET("/components", pricingHandler.ListComponents) triggerPull("projects", &projectsPullState, syncProjectsFromServer)
pricingAdmin.GET("/components/:lot_name", pricingHandler.GetComponentPricing) triggerPull("configs", &configsPullState, syncConfigurationsFromServer)
pricingAdmin.POST("/update", pricingHandler.UpdatePrice)
pricingAdmin.POST("/preview", pricingHandler.PreviewPrice)
pricingAdmin.POST("/recalculate-all", pricingHandler.RecalculateAll)
pricingAdmin.GET("/alerts", pricingHandler.ListAlerts) status := c.DefaultQuery("status", "active")
pricingAdmin.POST("/alerts/:id/acknowledge", pricingHandler.AcknowledgeAlert) search := strings.ToLower(strings.TrimSpace(c.Query("search")))
pricingAdmin.POST("/alerts/:id/resolve", pricingHandler.ResolveAlert) author := strings.ToLower(strings.TrimSpace(c.Query("author")))
pricingAdmin.POST("/alerts/:id/ignore", pricingHandler.IgnoreAlert) 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]
}
// Build per-project active config stats in one pass (avoid N+1 scans).
projectConfigCount := map[string]int{}
projectConfigTotal := map[string]float64{}
if localConfigs, cfgErr := local.GetConfigurations(); cfgErr == nil {
for i := range localConfigs {
cfg := localConfigs[i]
if !cfg.IsActive || cfg.ProjectUUID == nil || *cfg.ProjectUUID == "" {
continue
}
projectUUID := *cfg.ProjectUUID
projectConfigCount[projectUUID]++
if cfg.TotalPrice != nil {
projectConfigTotal[projectUUID] += *cfg.TotalPrice
}
}
}
projectRows := make([]gin.H, 0, len(paged))
for i := range paged {
p := paged[i]
projectRows = append(projectRows, gin.H{
"id": p.ID,
"uuid": p.UUID,
"owner_username": p.OwnerUsername,
"name": p.Name,
"tracker_url": p.TrackerURL,
"is_active": p.IsActive,
"is_system": p.IsSystem,
"created_at": p.CreatedAt,
"updated_at": p.UpdatedAt,
"config_count": projectConfigCount[p.UUID],
"total": projectConfigTotal[p.UUID],
})
}
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) {
triggerPull("configs", &configsPullState, 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)
})
} }
// Sync API (for offline mode) // Sync API (for offline mode)
syncAPI := api.Group("/sync") syncAPI := api.Group("/sync")
{ {
syncAPI.GET("/status", syncHandler.GetStatus) syncAPI.GET("/status", syncHandler.GetStatus)
syncAPI.GET("/readiness", syncHandler.GetReadiness)
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)

View File

@@ -37,7 +37,7 @@ func TestConfigurationVersioningAPI(t *testing.T) {
cfg := &config.Config{} cfg := &config.Config{}
setConfigDefaults(cfg) setConfigDefaults(cfg)
router, _, err := setupRouter(cfg, local, connMgr, nil, "tester") router, _, err := setupRouter(cfg, local, connMgr, nil, "tester", nil)
if err != nil { if err != nil {
t.Fatalf("setup router: %v", err) t.Fatalf("setup router: %v", err)
} }
@@ -136,6 +136,160 @@ func TestConfigurationVersioningAPI(t *testing.T) {
} }
} }
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) { func newAPITestStack(t *testing.T) (*localdb.LocalDB, *db.ConnectionManager, *services.LocalConfigurationService) {
t.Helper() t.Helper()

View File

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

View File

@@ -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 {

View File

@@ -3,8 +3,10 @@ package handlers
import ( import (
"net/http" "net/http"
"strconv" "strconv"
"strings"
"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/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" "github.com/gin-gonic/gin"
@@ -25,6 +27,12 @@ func NewComponentHandler(componentService *services.ComponentService, localDB *l
func (h *ComponentHandler) List(c *gin.Context) { func (h *ComponentHandler) 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"))
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 20
}
filter := repository.ComponentFilter{ filter := repository.ComponentFilter{
Category: c.Query("category"), Category: c.Query("category"),
@@ -33,73 +41,70 @@ func (h *ComponentHandler) List(c *gin.Context) {
ExcludeHidden: c.Query("include_hidden") != "true", // По умолчанию скрытые не показываются ExcludeHidden: c.Query("include_hidden") != "true", // По умолчанию скрытые не показываются
} }
result, err := h.componentService.List(filter, page, perPage) localFilter := localdb.ComponentFilter{
Category: filter.Category,
Search: filter.Search,
HasPrice: filter.HasPrice,
}
offset := (page - 1) * perPage
localComps, total, err := h.localDB.ListComponents(localFilter, offset, perPage)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
// If offline mode (empty result), fallback to local components components := make([]services.ComponentView, len(localComps))
isOffline := false for i, lc := range localComps {
if v, ok := c.Get("is_offline"); ok { components[i] = services.ComponentView{
if b, ok := v.(bool); ok { LotName: lc.LotName,
isOffline = b Description: lc.LotDescription,
} Category: lc.Category,
} CategoryName: lc.Category,
if isOffline && result.Total == 0 && h.localDB != nil { Model: lc.Model,
localFilter := localdb.ComponentFilter{ CurrentPrice: lc.CurrentPrice,
Category: filter.Category,
Search: filter.Search,
HasPrice: filter.HasPrice,
}
offset := (page - 1) * perPage
localComps, total, err := h.localDB.ListComponents(localFilter, offset, perPage)
if err == nil && len(localComps) > 0 {
// Convert local components to ComponentView format
components := make([]services.ComponentView, len(localComps))
for i, lc := range localComps {
components[i] = services.ComponentView{
LotName: lc.LotName,
Description: lc.LotDescription,
Category: lc.Category,
CategoryName: lc.Category, // No translation in local mode
Model: lc.Model,
CurrentPrice: lc.CurrentPrice,
}
}
c.JSON(http.StatusOK, &services.ComponentListResult{
Components: components,
Total: total,
Page: page,
PerPage: perPage,
})
return
} }
} }
c.JSON(http.StatusOK, result) c.JSON(http.StatusOK, &services.ComponentListResult{
Components: components,
Total: total,
Page: page,
PerPage: perPage,
})
} }
func (h *ComponentHandler) Get(c *gin.Context) { func (h *ComponentHandler) Get(c *gin.Context) {
lotName := c.Param("lot_name") lotName := c.Param("lot_name")
component, err := h.localDB.GetLocalComponent(lotName)
component, err := h.componentService.GetByLotName(lotName)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "component not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "component not found"})
return return
} }
c.JSON(http.StatusOK, component) c.JSON(http.StatusOK, services.ComponentView{
LotName: component.LotName,
Description: component.LotDescription,
Category: component.Category,
CategoryName: component.Category,
Model: component.Model,
CurrentPrice: component.CurrentPrice,
})
} }
func (h *ComponentHandler) GetCategories(c *gin.Context) { func (h *ComponentHandler) GetCategories(c *gin.Context) {
categories, err := h.componentService.GetCategories() codes, err := h.localDB.GetLocalComponentCategories()
if err != nil { if err == nil && len(codes) > 0 {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) categories := make([]models.Category, 0, len(codes))
for _, code := range codes {
trimmed := strings.TrimSpace(code)
if trimmed == "" {
continue
}
categories = append(categories, models.Category{Code: trimmed, Name: trimmed})
}
c.JSON(http.StatusOK, categories)
return return
} }
c.JSON(http.StatusOK, categories) c.JSON(http.StatusOK, models.DefaultCategories)
} }

View File

@@ -2,72 +2,93 @@ package handlers
import ( import (
"net/http" "net/http"
"sort"
"strconv" "strconv"
"strings"
"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/models"
"github.com/gin-gonic/gin"
) )
type PricelistHandler struct { type PricelistHandler struct {
service *pricelist.Service
localDB *localdb.LocalDB localDB *localdb.LocalDB
} }
func NewPricelistHandler(service *pricelist.Service, localDB *localdb.LocalDB) *PricelistHandler { func NewPricelistHandler(localDB *localdb.LocalDB) *PricelistHandler {
return &PricelistHandler{service: service, localDB: localDB} return &PricelistHandler{localDB: localDB}
} }
// List returns all pricelists with pagination // List returns all pricelists with pagination.
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"))
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 20
}
source := c.Query("source")
activeOnly := c.DefaultQuery("active_only", "false") == "true"
pricelists, total, err := h.service.List(page, perPage) localPLs, err := h.localDB.GetLocalPricelists()
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
} }
if source != "" {
// If offline (empty list), fallback to local pricelists filtered := localPLs[:0]
if total == 0 && h.localDB != nil { for _, lpl := range localPLs {
localPLs, err := h.localDB.GetLocalPricelists() if strings.EqualFold(lpl.Source, source) {
if err == nil && len(localPLs) > 0 { filtered = append(filtered, lpl)
// Convert to PricelistSummary format
summaries := make([]map[string]interface{}, len(localPLs))
for i, lpl := range localPLs {
summaries[i] = map[string]interface{}{
"id": lpl.ServerID,
"version": lpl.Version,
"created_by": "sync",
"item_count": 0, // Not tracked
"usage_count": 0, // Not tracked in local
"is_active": true,
"created_at": lpl.CreatedAt,
"synced_from": "local",
}
} }
c.JSON(http.StatusOK, gin.H{
"pricelists": summaries,
"total": len(summaries),
"page": page,
"per_page": perPage,
"offline": true,
})
return
} }
localPLs = filtered
}
if activeOnly {
// Local cache stores only active snapshots for normal operations.
}
sort.SliceStable(localPLs, func(i, j int) bool { return localPLs[i].CreatedAt.After(localPLs[j].CreatedAt) })
total := len(localPLs)
start := (page - 1) * perPage
if start > total {
start = total
}
end := start + perPage
if end > total {
end = total
}
pageSlice := localPLs[start:end]
summaries := make([]map[string]interface{}, 0, len(pageSlice))
for _, lpl := range pageSlice {
itemCount := h.localDB.CountLocalPricelistItems(lpl.ID)
usageCount := 0
if lpl.IsUsed {
usageCount = 1
}
summaries = append(summaries, map[string]interface{}{
"id": lpl.ServerID,
"source": lpl.Source,
"version": lpl.Version,
"created_by": "sync",
"item_count": itemCount,
"usage_count": usageCount,
"is_active": true,
"created_at": lpl.CreatedAt,
"synced_from": "local",
})
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"pricelists": pricelists, "pricelists": summaries,
"total": total, "total": total,
"page": page, "page": page,
"per_page": perPage, "per_page": perPage,
}) })
} }
// Get returns a single pricelist by ID // Get returns a single pricelist by ID.
func (h *PricelistHandler) Get(c *gin.Context) { func (h *PricelistHandler) Get(c *gin.Context) {
idStr := c.Param("id") idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32) id, err := strconv.ParseUint(idStr, 10, 32)
@@ -76,50 +97,25 @@ func (h *PricelistHandler) Get(c *gin.Context) {
return return
} }
pl, err := h.service.GetByID(uint(id)) localPL, err := h.localDB.GetLocalPricelistByServerID(uint(id))
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "pricelist not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "pricelist not found"})
return return
} }
c.JSON(http.StatusOK, pl) c.JSON(http.StatusOK, gin.H{
"id": localPL.ServerID,
"source": localPL.Source,
"version": localPL.Version,
"created_by": "sync",
"item_count": h.localDB.CountLocalPricelistItems(localPL.ID),
"is_active": true,
"created_at": localPL.CreatedAt,
"synced_from": "local",
})
} }
// Create creates a new pricelist from current prices // GetItems returns items for a pricelist with pagination.
func (h *PricelistHandler) Create(c *gin.Context) {
// Get the database username as the creator
createdBy := h.localDB.GetDBUser()
if createdBy == "" {
createdBy = "unknown"
}
pl, err := h.service.CreateFromCurrentPrices(createdBy)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, pl)
}
// Delete deletes a pricelist by ID
func (h *PricelistHandler) Delete(c *gin.Context) {
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
}
if err := h.service.Delete(uint(id)); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "pricelist deleted"})
}
// 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")
id, err := strconv.ParseUint(idStr, 10, 32) id, err := strconv.ParseUint(idStr, 10, 32)
@@ -132,57 +128,106 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50")) perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50"))
search := c.Query("search") search := c.Query("search")
items, total, err := h.service.GetItems(uint(id), page, perPage, search) localPL, err := h.localDB.GetLocalPricelistByServerID(uint(id))
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "pricelist not found"})
return
}
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 50
}
var items []localdb.LocalPricelistItem
dbq := h.localDB.DB().Model(&localdb.LocalPricelistItem{}).Where("pricelist_id = ?", localPL.ID)
if strings.TrimSpace(search) != "" {
dbq = dbq.Where("lot_name LIKE ?", "%"+strings.TrimSpace(search)+"%")
}
var total int64
if err := dbq.Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
offset := (page - 1) * perPage
if err := dbq.Order("lot_name").Offset(offset).Limit(perPage).Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
resultItems := make([]gin.H, 0, len(items))
for _, item := range items {
category := ""
if parts := strings.SplitN(item.LotName, "_", 2); len(parts) > 0 {
category = parts[0]
}
resultItems = append(resultItems, gin.H{
"id": item.ID,
"lot_name": item.LotName,
"price": item.Price,
"category": category,
"available_qty": item.AvailableQty,
"partnumbers": []string(item.Partnumbers),
})
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"items": items, "source": localPL.Source,
"items": resultItems,
"total": total, "total": total,
"page": page, "page": page,
"per_page": perPage, "per_page": perPage,
}) })
} }
// CanWrite returns whether the current user can create pricelists func (h *PricelistHandler) GetLotNames(c *gin.Context) {
func (h *PricelistHandler) CanWrite(c *gin.Context) { idStr := c.Param("id")
canWrite, debugInfo := h.service.CanWriteDebug() id, err := strconv.ParseUint(idStr, 10, 32)
c.JSON(http.StatusOK, gin.H{"can_write": canWrite, "debug": debugInfo})
}
// GetLatest returns the most recent active pricelist
func (h *PricelistHandler) GetLatest(c *gin.Context) {
// Try to get from server first
pl, err := h.service.GetLatestActive()
if err != nil { if err != nil {
// If offline or no server pricelists, try to get from local cache c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
if h.localDB == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no database available"})
return
}
localPL, localErr := h.localDB.GetLatestLocalPricelist()
if localErr != nil {
// No local pricelists either
c.JSON(http.StatusNotFound, gin.H{
"error": "no pricelists available",
"local_error": localErr.Error(),
})
return
}
// Return local pricelist
c.JSON(http.StatusOK, gin.H{
"id": localPL.ServerID,
"version": localPL.Version,
"created_by": "sync",
"item_count": 0, // Not tracked in local pricelists
"is_active": true,
"created_at": localPL.CreatedAt,
"synced_from": "local",
})
return return
} }
c.JSON(http.StatusOK, pl) localPL, err := h.localDB.GetLocalPricelistByServerID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "pricelist not found"})
return
}
items, err := h.localDB.GetLocalPricelistItems(localPL.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
lotNames := make([]string, 0, len(items))
for _, item := range items {
lotNames = append(lotNames, item.LotName)
}
sort.Strings(lotNames)
c.JSON(http.StatusOK, gin.H{
"lot_names": lotNames,
"total": len(lotNames),
})
}
// GetLatest returns the most recent active pricelist.
func (h *PricelistHandler) GetLatest(c *gin.Context) {
source := c.DefaultQuery("source", string(models.PricelistSourceEstimate))
source = string(models.NormalizePricelistSource(source))
localPL, err := h.localDB.GetLatestLocalPricelistBySource(source)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no pricelists available"})
return
}
c.JSON(http.StatusOK, gin.H{
"id": localPL.ServerID,
"source": localPL.Source,
"version": localPL.Version,
"created_by": "sync",
"item_count": h.localDB.CountLocalPricelistItems(localPL.ID),
"is_active": true,
"created_at": localPL.CreatedAt,
"synced_from": "local",
})
} }

View File

@@ -1,938 +0,0 @@
package handlers
import (
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services/alerts"
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
"gorm.io/gorm"
)
// calculateMedian returns the median of a sorted slice of prices
func calculateMedian(prices []float64) float64 {
if len(prices) == 0 {
return 0
}
sort.Float64s(prices)
n := len(prices)
if n%2 == 0 {
return (prices[n/2-1] + prices[n/2]) / 2
}
return prices[n/2]
}
// calculateAverage returns the arithmetic mean of prices
func calculateAverage(prices []float64) float64 {
if len(prices) == 0 {
return 0
}
var sum float64
for _, p := range prices {
sum += p
}
return sum / float64(len(prices))
}
type PricingHandler struct {
db *gorm.DB
pricingService *pricing.Service
alertService *alerts.Service
componentRepo *repository.ComponentRepository
priceRepo *repository.PriceRepository
statsRepo *repository.StatsRepository
}
func NewPricingHandler(
db *gorm.DB,
pricingService *pricing.Service,
alertService *alerts.Service,
componentRepo *repository.ComponentRepository,
priceRepo *repository.PriceRepository,
statsRepo *repository.StatsRepository,
) *PricingHandler {
return &PricingHandler{
db: db,
pricingService: pricingService,
alertService: alertService,
componentRepo: componentRepo,
priceRepo: priceRepo,
statsRepo: statsRepo,
}
}
func (h *PricingHandler) GetStats(c *gin.Context) {
// Check if we're in offline mode
if h.statsRepo == nil || h.alertService == nil {
c.JSON(http.StatusOK, gin.H{
"new_alerts_count": 0,
"top_components": []interface{}{},
"trending_components": []interface{}{},
"offline": true,
})
return
}
newAlerts, _ := h.alertService.GetNewAlertsCount()
topComponents, _ := h.statsRepo.GetTopComponents(10)
trendingComponents, _ := h.statsRepo.GetTrendingComponents(10)
c.JSON(http.StatusOK, gin.H{
"new_alerts_count": newAlerts,
"top_components": topComponents,
"trending_components": trendingComponents,
})
}
type ComponentWithCount struct {
models.LotMetadata
QuoteCount int64 `json:"quote_count"`
UsedInMeta []string `json:"used_in_meta,omitempty"` // List of meta-articles that use this component
}
func (h *PricingHandler) ListComponents(c *gin.Context) {
// Check if we're in offline mode
if h.componentRepo == nil {
c.JSON(http.StatusOK, gin.H{
"components": []ComponentWithCount{},
"total": 0,
"page": 1,
"per_page": 20,
"offline": true,
"message": "Управление ценами доступно только в онлайн режиме",
})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
filter := repository.ComponentFilter{
Category: c.Query("category"),
Search: c.Query("search"),
SortField: c.Query("sort"),
SortDir: c.Query("dir"),
}
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 100 {
perPage = 20
}
offset := (page - 1) * perPage
components, total, err := h.componentRepo.List(filter, offset, perPage)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Get quote counts
lotNames := make([]string, len(components))
for i, comp := range components {
lotNames[i] = comp.LotName
}
counts, _ := h.priceRepo.GetQuoteCounts(lotNames)
// Get meta usage information
metaUsage := h.getMetaUsageMap(lotNames)
// Combine components with counts
result := make([]ComponentWithCount, len(components))
for i, comp := range components {
result[i] = ComponentWithCount{
LotMetadata: comp,
QuoteCount: counts[comp.LotName],
UsedInMeta: metaUsage[comp.LotName],
}
}
c.JSON(http.StatusOK, gin.H{
"components": result,
"total": total,
"page": page,
"per_page": perPage,
})
}
// getMetaUsageMap returns a map of lot_name -> list of meta-articles that use this component
func (h *PricingHandler) getMetaUsageMap(lotNames []string) map[string][]string {
result := make(map[string][]string)
// Get all components with meta_prices
var metaComponents []models.LotMetadata
h.db.Where("meta_prices IS NOT NULL AND meta_prices != ''").Find(&metaComponents)
// Build reverse lookup: which components are used in which meta-articles
for _, meta := range metaComponents {
sources := strings.Split(meta.MetaPrices, ",")
for _, source := range sources {
source = strings.TrimSpace(source)
if source == "" {
continue
}
// Handle wildcard patterns
if strings.HasSuffix(source, "*") {
prefix := strings.TrimSuffix(source, "*")
for _, lotName := range lotNames {
if strings.HasPrefix(lotName, prefix) && lotName != meta.LotName {
result[lotName] = append(result[lotName], meta.LotName)
}
}
} else {
// Direct match
for _, lotName := range lotNames {
if lotName == source && lotName != meta.LotName {
result[lotName] = append(result[lotName], meta.LotName)
}
}
}
}
}
return result
}
// expandMetaPrices expands meta_prices string to list of actual lot names
func (h *PricingHandler) expandMetaPrices(metaPrices, excludeLot string) []string {
sources := strings.Split(metaPrices, ",")
var result []string
seen := make(map[string]bool)
for _, source := range sources {
source = strings.TrimSpace(source)
if source == "" {
continue
}
if strings.HasSuffix(source, "*") {
// Wildcard pattern - find matching lots
prefix := strings.TrimSuffix(source, "*")
var matchingLots []string
h.db.Model(&models.LotMetadata{}).
Where("lot_name LIKE ? AND lot_name != ?", prefix+"%", excludeLot).
Pluck("lot_name", &matchingLots)
for _, lot := range matchingLots {
if !seen[lot] {
result = append(result, lot)
seen[lot] = true
}
}
} else if source != excludeLot && !seen[source] {
result = append(result, source)
seen[source] = true
}
}
return result
}
func (h *PricingHandler) GetComponentPricing(c *gin.Context) {
// Check if we're in offline mode
if h.componentRepo == nil || h.pricingService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Управление ценами доступно только в онлайн режиме",
"offline": true,
})
return
}
lotName := c.Param("lot_name")
component, err := h.componentRepo.GetByLotName(lotName)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "component not found"})
return
}
stats, err := h.pricingService.GetPriceStats(lotName, 0)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"component": component,
"price_stats": stats,
})
}
type UpdatePriceRequest struct {
LotName string `json:"lot_name" binding:"required"`
Method models.PriceMethod `json:"method"`
PeriodDays int `json:"period_days"`
Coefficient float64 `json:"coefficient"`
ManualPrice *float64 `json:"manual_price"`
ClearManual bool `json:"clear_manual"`
MetaEnabled bool `json:"meta_enabled"`
MetaPrices string `json:"meta_prices"`
MetaMethod string `json:"meta_method"`
MetaPeriod int `json:"meta_period"`
IsHidden bool `json:"is_hidden"`
}
func (h *PricingHandler) UpdatePrice(c *gin.Context) {
// Check if we're in offline mode
if h.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Обновление цен доступно только в онлайн режиме",
"offline": true,
})
return
}
var req UpdatePriceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
updates := map[string]interface{}{}
// Update method if specified
if req.Method != "" {
updates["price_method"] = req.Method
}
// Update period days
if req.PeriodDays >= 0 {
updates["price_period_days"] = req.PeriodDays
}
// Update coefficient
updates["price_coefficient"] = req.Coefficient
// Handle meta prices
if req.MetaEnabled && req.MetaPrices != "" {
updates["meta_prices"] = req.MetaPrices
} else {
updates["meta_prices"] = ""
}
// Handle hidden flag
updates["is_hidden"] = req.IsHidden
// Handle manual price
if req.ClearManual {
updates["manual_price"] = nil
} else if req.ManualPrice != nil {
updates["manual_price"] = *req.ManualPrice
// Also update current price immediately when setting manual
updates["current_price"] = *req.ManualPrice
updates["price_updated_at"] = time.Now()
}
err := h.db.Model(&models.LotMetadata{}).
Where("lot_name = ?", req.LotName).
Updates(updates).Error
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Recalculate price if not using manual price
if req.ManualPrice == nil {
h.recalculateSinglePrice(req.LotName)
}
// Get updated component to return new price
var comp models.LotMetadata
h.db.Where("lot_name = ?", req.LotName).First(&comp)
c.JSON(http.StatusOK, gin.H{
"message": "price updated",
"current_price": comp.CurrentPrice,
})
}
func (h *PricingHandler) recalculateSinglePrice(lotName string) {
var comp models.LotMetadata
if err := h.db.Where("lot_name = ?", lotName).First(&comp).Error; err != nil {
return
}
// Skip if manual price is set
if comp.ManualPrice != nil && *comp.ManualPrice > 0 {
return
}
periodDays := comp.PricePeriodDays
method := comp.PriceMethod
if method == "" {
method = models.PriceMethodMedian
}
// Determine which lot names to use for price calculation
lotNames := []string{lotName}
if comp.MetaPrices != "" {
lotNames = h.expandMetaPrices(comp.MetaPrices, lotName)
}
// Get prices based on period from all relevant lots
var prices []float64
for _, ln := range lotNames {
var lotPrices []float64
if strings.HasSuffix(ln, "*") {
pattern := strings.TrimSuffix(ln, "*") + "%"
if periodDays > 0 {
h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
pattern, periodDays).Pluck("price", &lotPrices)
} else {
h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? ORDER BY price`, pattern).Pluck("price", &lotPrices)
}
} else {
if periodDays > 0 {
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
ln, periodDays).Pluck("price", &lotPrices)
} else {
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, ln).Pluck("price", &lotPrices)
}
}
prices = append(prices, lotPrices...)
}
// If no prices in period, try all time
if len(prices) == 0 && periodDays > 0 {
for _, ln := range lotNames {
var lotPrices []float64
if strings.HasSuffix(ln, "*") {
pattern := strings.TrimSuffix(ln, "*") + "%"
h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? ORDER BY price`, pattern).Pluck("price", &lotPrices)
} else {
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, ln).Pluck("price", &lotPrices)
}
prices = append(prices, lotPrices...)
}
}
if len(prices) == 0 {
return
}
// Calculate price based on method
sortFloat64s(prices)
var finalPrice float64
switch method {
case models.PriceMethodMedian:
finalPrice = calculateMedian(prices)
case models.PriceMethodAverage:
finalPrice = calculateAverage(prices)
default:
finalPrice = calculateMedian(prices)
}
if finalPrice <= 0 {
return
}
// Apply coefficient
if comp.PriceCoefficient != 0 {
finalPrice = finalPrice * (1 + comp.PriceCoefficient/100)
}
now := time.Now()
// Only update price, preserve all user settings
h.db.Model(&models.LotMetadata{}).
Where("lot_name = ?", lotName).
Updates(map[string]interface{}{
"current_price": finalPrice,
"price_updated_at": now,
})
}
func (h *PricingHandler) RecalculateAll(c *gin.Context) {
// Check if we're in offline mode
if h.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Пересчёт цен доступен только в онлайн режиме",
"offline": true,
})
return
}
// Set headers for SSE
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
// Get all components with their settings
var components []models.LotMetadata
h.db.Find(&components)
total := int64(len(components))
// Pre-load all lot names for efficient wildcard matching
var allLotNames []string
h.db.Model(&models.LotMetadata{}).Pluck("lot_name", &allLotNames)
lotNameSet := make(map[string]bool, len(allLotNames))
for _, ln := range allLotNames {
lotNameSet[ln] = true
}
// Pre-load latest quote dates for all lots (for checking updates)
type LotDate struct {
Lot string
Date time.Time
}
var latestDates []LotDate
h.db.Raw(`SELECT lot, MAX(date) as date FROM lot_log GROUP BY lot`).Scan(&latestDates)
lotLatestDate := make(map[string]time.Time, len(latestDates))
for _, ld := range latestDates {
lotLatestDate[ld.Lot] = ld.Date
}
// Send initial progress
c.SSEvent("progress", gin.H{"current": 0, "total": total, "status": "starting"})
c.Writer.Flush()
// Process components individually to respect their settings
var updated, skipped, manual, unchanged, errors int
now := time.Now()
progressCounter := 0
for _, comp := range components {
progressCounter++
// If manual price is set, skip recalculation
if comp.ManualPrice != nil && *comp.ManualPrice > 0 {
manual++
goto sendProgress
}
// Calculate price based on component's individual settings
{
periodDays := comp.PricePeriodDays
method := comp.PriceMethod
if method == "" {
method = models.PriceMethodMedian
}
// Determine source lots for price calculation (using cached lot names)
var sourceLots []string
if comp.MetaPrices != "" {
sourceLots = expandMetaPricesWithCache(comp.MetaPrices, comp.LotName, allLotNames)
} else {
sourceLots = []string{comp.LotName}
}
if len(sourceLots) == 0 {
skipped++
goto sendProgress
}
// Check if there are new quotes since last update (using cached dates)
if comp.PriceUpdatedAt != nil {
hasNewData := false
for _, lot := range sourceLots {
if latestDate, ok := lotLatestDate[lot]; ok {
if latestDate.After(*comp.PriceUpdatedAt) {
hasNewData = true
break
}
}
}
if !hasNewData {
unchanged++
goto sendProgress
}
}
// Get prices from source lots
var prices []float64
if periodDays > 0 {
h.db.Raw(`SELECT price FROM lot_log WHERE lot IN ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
sourceLots, periodDays).Pluck("price", &prices)
} else {
h.db.Raw(`SELECT price FROM lot_log WHERE lot IN ? ORDER BY price`,
sourceLots).Pluck("price", &prices)
}
// If no prices in period, try all time
if len(prices) == 0 && periodDays > 0 {
h.db.Raw(`SELECT price FROM lot_log WHERE lot IN ? ORDER BY price`, sourceLots).Pluck("price", &prices)
}
if len(prices) == 0 {
skipped++
goto sendProgress
}
// Calculate price based on method
var basePrice float64
switch method {
case models.PriceMethodMedian:
basePrice = calculateMedian(prices)
case models.PriceMethodAverage:
basePrice = calculateAverage(prices)
default:
basePrice = calculateMedian(prices)
}
if basePrice <= 0 {
skipped++
goto sendProgress
}
finalPrice := basePrice
// Apply coefficient
if comp.PriceCoefficient != 0 {
finalPrice = finalPrice * (1 + comp.PriceCoefficient/100)
}
// Update only price fields
err := h.db.Model(&models.LotMetadata{}).
Where("lot_name = ?", comp.LotName).
Updates(map[string]interface{}{
"current_price": finalPrice,
"price_updated_at": now,
}).Error
if err != nil {
errors++
} else {
updated++
}
}
sendProgress:
// Send progress update every 10 components to reduce overhead
if progressCounter%10 == 0 || progressCounter == int(total) {
c.SSEvent("progress", gin.H{
"current": updated + skipped + manual + unchanged + errors,
"total": total,
"updated": updated,
"skipped": skipped,
"manual": manual,
"unchanged": unchanged,
"errors": errors,
"status": "processing",
"lot_name": comp.LotName,
})
c.Writer.Flush()
}
}
// Update popularity scores
h.statsRepo.UpdatePopularityScores()
// Send completion
c.SSEvent("progress", gin.H{
"current": updated + skipped + manual + unchanged + errors,
"total": total,
"updated": updated,
"skipped": skipped,
"manual": manual,
"unchanged": unchanged,
"errors": errors,
"status": "completed",
})
c.Writer.Flush()
}
func (h *PricingHandler) ListAlerts(c *gin.Context) {
// Check if we're in offline mode
if h.db == nil {
c.JSON(http.StatusOK, gin.H{
"alerts": []interface{}{},
"total": 0,
"page": 1,
"per_page": 20,
"offline": true,
})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
filter := repository.AlertFilter{
Status: models.AlertStatus(c.Query("status")),
Severity: models.AlertSeverity(c.Query("severity")),
Type: models.AlertType(c.Query("type")),
LotName: c.Query("lot_name"),
}
alertsList, total, err := h.alertService.List(filter, page, perPage)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"alerts": alertsList,
"total": total,
"page": page,
"per_page": perPage,
})
}
func (h *PricingHandler) AcknowledgeAlert(c *gin.Context) {
// Check if we're in offline mode
if h.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Управление алертами доступно только в онлайн режиме",
"offline": true,
})
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"})
return
}
if err := h.alertService.Acknowledge(uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "acknowledged"})
}
func (h *PricingHandler) ResolveAlert(c *gin.Context) {
// Check if we're in offline mode
if h.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Управление алертами доступно только в онлайн режиме",
"offline": true,
})
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"})
return
}
if err := h.alertService.Resolve(uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "resolved"})
}
func (h *PricingHandler) IgnoreAlert(c *gin.Context) {
// Check if we're in offline mode
if h.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Управление алертами доступно только в онлайн режиме",
"offline": true,
})
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"})
return
}
if err := h.alertService.Ignore(uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "ignored"})
}
type PreviewPriceRequest struct {
LotName string `json:"lot_name" binding:"required"`
Method string `json:"method"`
PeriodDays int `json:"period_days"`
Coefficient float64 `json:"coefficient"`
MetaEnabled bool `json:"meta_enabled"`
MetaPrices string `json:"meta_prices"`
}
func (h *PricingHandler) PreviewPrice(c *gin.Context) {
// Check if we're in offline mode
if h.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Предпросмотр цены доступен только в онлайн режиме",
"offline": true,
})
return
}
var req PreviewPriceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Get component
var comp models.LotMetadata
if err := h.db.Where("lot_name = ?", req.LotName).First(&comp).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "component not found"})
return
}
// Determine which lot names to use for price calculation
lotNames := []string{req.LotName}
if req.MetaEnabled && req.MetaPrices != "" {
lotNames = h.expandMetaPrices(req.MetaPrices, req.LotName)
}
// Get all prices for calculations (from all relevant lots)
var allPrices []float64
for _, lotName := range lotNames {
var lotPrices []float64
if strings.HasSuffix(lotName, "*") {
// Wildcard pattern
pattern := strings.TrimSuffix(lotName, "*") + "%"
h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? ORDER BY price`, pattern).Pluck("price", &lotPrices)
} else {
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, lotName).Pluck("price", &lotPrices)
}
allPrices = append(allPrices, lotPrices...)
}
// Calculate median for all time
var medianAllTime *float64
if len(allPrices) > 0 {
sortFloat64s(allPrices)
median := calculateMedian(allPrices)
medianAllTime = &median
}
// Get quote count (from all relevant lots) - total count
var quoteCountTotal int64
for _, lotName := range lotNames {
var count int64
if strings.HasSuffix(lotName, "*") {
pattern := strings.TrimSuffix(lotName, "*") + "%"
h.db.Model(&models.LotLog{}).Where("lot LIKE ?", pattern).Count(&count)
} else {
h.db.Model(&models.LotLog{}).Where("lot = ?", lotName).Count(&count)
}
quoteCountTotal += count
}
// Get quote count for specified period (if period is > 0)
var quoteCountPeriod int64
if req.PeriodDays > 0 {
for _, lotName := range lotNames {
var count int64
if strings.HasSuffix(lotName, "*") {
pattern := strings.TrimSuffix(lotName, "*") + "%"
h.db.Raw(`SELECT COUNT(*) FROM lot_log WHERE lot LIKE ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY)`, pattern, req.PeriodDays).Scan(&count)
} else {
h.db.Raw(`SELECT COUNT(*) FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY)`, lotName, req.PeriodDays).Scan(&count)
}
quoteCountPeriod += count
}
} else {
// If no period specified, period count equals total count
quoteCountPeriod = quoteCountTotal
}
// Get last received price (from the main lot only)
var lastPrice struct {
Price *float64
Date *time.Time
}
h.db.Raw(`SELECT price, date FROM lot_log WHERE lot = ? ORDER BY date DESC, lot_log_id DESC LIMIT 1`, req.LotName).Scan(&lastPrice)
// Calculate new price based on parameters (method, period, coefficient)
method := req.Method
if method == "" {
method = "median"
}
var prices []float64
if req.PeriodDays > 0 {
for _, lotName := range lotNames {
var lotPrices []float64
if strings.HasSuffix(lotName, "*") {
pattern := strings.TrimSuffix(lotName, "*") + "%"
h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
pattern, req.PeriodDays).Pluck("price", &lotPrices)
} else {
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
lotName, req.PeriodDays).Pluck("price", &lotPrices)
}
prices = append(prices, lotPrices...)
}
// Fall back to all time if no prices in period
if len(prices) == 0 {
prices = allPrices
}
} else {
prices = allPrices
}
var newPrice *float64
if len(prices) > 0 {
sortFloat64s(prices)
var basePrice float64
if method == "average" {
basePrice = calculateAverage(prices)
} else {
basePrice = calculateMedian(prices)
}
if req.Coefficient != 0 {
basePrice = basePrice * (1 + req.Coefficient/100)
}
newPrice = &basePrice
}
c.JSON(http.StatusOK, gin.H{
"lot_name": req.LotName,
"current_price": comp.CurrentPrice,
"median_all_time": medianAllTime,
"new_price": newPrice,
"quote_count_total": quoteCountTotal,
"quote_count_period": quoteCountPeriod,
"manual_price": comp.ManualPrice,
"last_price": lastPrice.Price,
"last_price_date": lastPrice.Date,
})
}
// sortFloat64s sorts a slice of float64 in ascending order
func sortFloat64s(data []float64) {
sort.Float64s(data)
}
// expandMetaPricesWithCache expands meta_prices using pre-loaded lot names (no DB queries)
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, "*") {
// Wildcard pattern - find matching lots from cache
prefix := strings.TrimSuffix(source, "*")
for _, lot := range allLotNames {
if strings.HasPrefix(lot, prefix) && lot != excludeLot && !seen[lot] {
result = append(result, lot)
seen[lot] = true
}
}
} else if !seen[source] {
result = append(result, source)
seen[source] = true
}
}
return result
}

View File

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

View File

@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"html/template" "html/template"
"log/slog" "log/slog"
"net"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@@ -13,8 +14,9 @@ import (
qfassets "git.mchus.pro/mchus/quoteforge" 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"
mysqlDriver "github.com/go-sql-driver/mysql"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/driver/mysql" gormmysql "gorm.io/driver/mysql"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
) )
@@ -93,10 +95,9 @@ func (h *SetupHandler) TestConnection(c *gin.Context) {
} }
} }
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s", 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 {
@@ -148,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")
@@ -167,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 {
@@ -202,19 +204,29 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) {
} }
} }
// Always restart to properly initialize all services with the new connection settingsChanged := existingSettings == nil ||
restartRequired := h.restartSig == 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.", "message": "Settings saved.",
"restart_required": restartRequired, "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:
}
}() }()
} }
} }
@@ -242,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()
}

View File

@@ -1,11 +1,14 @@
package handlers package handlers
import ( import (
"errors"
"fmt"
"html/template" "html/template"
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
stdsync "sync"
"time" "time"
qfassets "git.mchus.pro/mchus/quoteforge" qfassets "git.mchus.pro/mchus/quoteforge"
@@ -17,14 +20,19 @@ import (
// 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
readinessMu stdsync.Mutex
readinessCached *sync.SyncReadiness
readinessCachedAt time.Time
} }
// 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")
var tmpl *template.Template var tmpl *template.Template
@@ -39,23 +47,35 @@ func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr
} }
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"`
Readiness *sync.SyncReadiness `json:"readiness,omitempty"`
}
type SyncReadinessResponse struct {
Status string `json:"status"`
Blocked bool `json:"blocked"`
ReasonCode string `json:"reason_code,omitempty"`
ReasonText string `json:"reason_text,omitempty"`
RequiredMinAppVersion *string `json:"required_min_app_version,omitempty"`
LastCheckedAt *time.Time `json:"last_checked_at,omitempty"`
} }
// GetStatus returns current sync status // GetStatus returns current sync status
@@ -85,6 +105,7 @@ func (h *SyncHandler) GetStatus(c *gin.Context) {
// Check if component sync is needed (older than 24 hours) // Check if component sync is needed (older than 24 hours)
needComponentSync := h.localDB.NeedComponentSync(24) needComponentSync := h.localDB.NeedComponentSync(24)
readiness := h.getReadinessCached(10 * time.Second)
c.JSON(http.StatusOK, SyncStatusResponse{ c.JSON(http.StatusOK, SyncStatusResponse{
LastComponentSync: lastComponentSync, LastComponentSync: lastComponentSync,
@@ -95,9 +116,63 @@ func (h *SyncHandler) GetStatus(c *gin.Context) {
ServerPricelists: serverPricelists, ServerPricelists: serverPricelists,
NeedComponentSync: needComponentSync, NeedComponentSync: needComponentSync,
NeedPricelistSync: needPricelistSync, NeedPricelistSync: needPricelistSync,
Readiness: readiness,
}) })
} }
// GetReadiness returns sync readiness guard status.
// GET /api/sync/readiness
func (h *SyncHandler) GetReadiness(c *gin.Context) {
readiness, err := h.syncService.GetReadiness()
if err != nil && readiness == nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}
if readiness == nil {
c.JSON(http.StatusOK, SyncReadinessResponse{Status: sync.ReadinessUnknown, Blocked: false})
return
}
c.JSON(http.StatusOK, SyncReadinessResponse{
Status: readiness.Status,
Blocked: readiness.Blocked,
ReasonCode: readiness.ReasonCode,
ReasonText: readiness.ReasonText,
RequiredMinAppVersion: readiness.RequiredMinAppVersion,
LastCheckedAt: readiness.LastCheckedAt,
})
}
func (h *SyncHandler) ensureSyncReadiness(c *gin.Context) bool {
readiness, err := h.syncService.EnsureReadinessForSync()
if err == nil {
return true
}
blocked := &sync.SyncBlockedError{}
if errors.As(err, &blocked) {
c.JSON(http.StatusLocked, gin.H{
"success": false,
"error": blocked.Error(),
"reason_code": blocked.Readiness.ReasonCode,
"reason_text": blocked.Readiness.ReasonText,
"required_min_app_version": blocked.Readiness.RequiredMinAppVersion,
"status": blocked.Readiness.Status,
"blocked": true,
"last_checked_at": blocked.Readiness.LastCheckedAt,
})
return false
}
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": err.Error(),
})
_ = readiness
return false
}
// SyncResultResponse represents sync operation result // SyncResultResponse represents sync operation result
type SyncResultResponse struct { type SyncResultResponse struct {
Success bool `json:"success"` Success bool `json:"success"`
@@ -109,11 +184,7 @@ type SyncResultResponse struct {
// SyncComponents syncs components from MariaDB to local SQLite // SyncComponents syncs components from MariaDB to local SQLite
// POST /api/sync/components // POST /api/sync/components
func (h *SyncHandler) SyncComponents(c *gin.Context) { func (h *SyncHandler) SyncComponents(c *gin.Context) {
if !h.checkOnline() { if !h.ensureSyncReadiness(c) {
c.JSON(http.StatusServiceUnavailable, gin.H{
"success": false,
"error": "Database is offline",
})
return return
} }
@@ -148,11 +219,7 @@ func (h *SyncHandler) SyncComponents(c *gin.Context) {
// SyncPricelists syncs pricelists from MariaDB to local SQLite // SyncPricelists syncs pricelists from MariaDB to local SQLite
// POST /api/sync/pricelists // POST /api/sync/pricelists
func (h *SyncHandler) SyncPricelists(c *gin.Context) { func (h *SyncHandler) SyncPricelists(c *gin.Context) {
if !h.checkOnline() { if !h.ensureSyncReadiness(c) {
c.JSON(http.StatusServiceUnavailable, gin.H{
"success": false,
"error": "Database is offline",
})
return return
} }
@@ -173,30 +240,47 @@ 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"` PendingPushed int `json:"pending_pushed"`
PricelistsSynced int `json:"pricelists_synced"` ComponentsSynced int `json:"components_synced"`
Duration string `json:"duration"` PricelistsSynced int `json:"pricelists_synced"`
ProjectsImported int `json:"projects_imported"`
ProjectsUpdated int `json:"projects_updated"`
ProjectsSkipped int `json:"projects_skipped"`
ConfigurationsImported int `json:"configurations_imported"`
ConfigurationsUpdated int `json:"configurations_updated"`
ConfigurationsSkipped int `json:"configurations_skipped"`
Duration string `json:"duration"`
} }
// SyncAll syncs both components and pricelists // SyncAll performs full bidirectional sync:
// - push pending local changes (projects/configurations) to server
// - pull components, pricelists, projects, and configurations from server
// POST /api/sync/all // POST /api/sync/all
func (h *SyncHandler) SyncAll(c *gin.Context) { func (h *SyncHandler) SyncAll(c *gin.Context) {
if !h.checkOnline() { if !h.ensureSyncReadiness(c) {
c.JSON(http.StatusServiceUnavailable, gin.H{
"success": false,
"error": "Database is offline",
})
return return
} }
startTime := time.Now() startTime := time.Now()
var componentsSynced, pricelistsSynced int var pendingPushed, componentsSynced, pricelistsSynced int
// Push local pending changes first (projects/configurations)
pendingPushed, err := h.syncService.PushPendingChanges()
if err != nil {
slog.Error("pending push failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "Pending changes push failed: " + err.Error(),
})
return
}
// Sync components // Sync components
mariaDB, err := h.connMgr.GetDB() mariaDB, err := h.connMgr.GetDB()
@@ -226,18 +310,56 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
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(),
"pending_pushed": pendingPushed,
"components_synced": componentsSynced, "components_synced": componentsSynced,
}) })
return return
} }
projectsResult, err := h.syncService.ImportProjectsToLocal()
if err != nil {
slog.Error("project import failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "Project import failed: " + err.Error(),
"pending_pushed": pendingPushed,
"components_synced": componentsSynced,
"pricelists_synced": pricelistsSynced,
})
return
}
configsResult, err := h.syncService.ImportConfigurationsToLocal()
if err != nil {
slog.Error("configuration import failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "Configuration import failed: " + err.Error(),
"pending_pushed": pendingPushed,
"components_synced": componentsSynced,
"pricelists_synced": pricelistsSynced,
"projects_imported": projectsResult.Imported,
"projects_updated": projectsResult.Updated,
"projects_skipped": projectsResult.Skipped,
})
return
}
c.JSON(http.StatusOK, SyncAllResponse{ c.JSON(http.StatusOK, SyncAllResponse{
Success: true, Success: true,
Message: "Full sync completed successfully", Message: "Full sync completed successfully",
ComponentsSynced: componentsSynced, PendingPushed: pendingPushed,
PricelistsSynced: pricelistsSynced, ComponentsSynced: componentsSynced,
Duration: time.Since(startTime).String(), PricelistsSynced: pricelistsSynced,
ProjectsImported: projectsResult.Imported,
ProjectsUpdated: projectsResult.Updated,
ProjectsSkipped: projectsResult.Skipped,
ConfigurationsImported: configsResult.Imported,
ConfigurationsUpdated: configsResult.Updated,
ConfigurationsSkipped: configsResult.Skipped,
Duration: time.Since(startTime).String(),
}) })
h.syncService.RecordSyncHeartbeat()
} }
// checkOnline checks if MariaDB is accessible // checkOnline checks if MariaDB is accessible
@@ -248,11 +370,7 @@ func (h *SyncHandler) checkOnline() bool {
// PushPendingChanges pushes all pending changes to the server // PushPendingChanges pushes all pending changes to the server
// POST /api/sync/push // POST /api/sync/push
func (h *SyncHandler) PushPendingChanges(c *gin.Context) { func (h *SyncHandler) PushPendingChanges(c *gin.Context) {
if !h.checkOnline() { if !h.ensureSyncReadiness(c) {
c.JSON(http.StatusServiceUnavailable, gin.H{
"success": false,
"error": "Database is offline",
})
return return
} }
@@ -273,6 +391,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
@@ -300,12 +419,40 @@ func (h *SyncHandler) GetPendingChanges(c *gin.Context) {
}) })
} }
// SyncInfoResponse represents sync information // SyncInfoResponse represents sync information for the modal
type SyncInfoResponse struct { type SyncInfoResponse struct {
LastSyncAt *time.Time `json:"last_sync_at"` // Connection
IsOnline bool `json:"is_online"` DBHost string `json:"db_host"`
DBUser string `json:"db_user"`
DBName string `json:"db_name"`
// Status
IsOnline bool `json:"is_online"`
LastSyncAt *time.Time `json:"last_sync_at"`
// Statistics
LotCount int64 `json:"lot_count"`
LotLogCount int64 `json:"lot_log_count"`
ConfigCount int64 `json:"config_count"`
ProjectCount int64 `json:"project_count"`
// Pending changes
PendingChanges []localdb.PendingChange `json:"pending_changes"`
// Errors
ErrorCount int `json:"error_count"` ErrorCount int `json:"error_count"`
Errors []SyncError `json:"errors,omitempty"` Errors []SyncError `json:"errors,omitempty"`
// Readiness guard
Readiness *sync.SyncReadiness `json:"readiness,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
@@ -320,31 +467,44 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
// Check online status by pinging MariaDB // Check online status by pinging MariaDB
isOnline := h.checkOnline() isOnline := h.checkOnline()
// Get DB connection info
var dbHost, dbUser, dbName string
if settings, err := h.localDB.GetSettings(); err == nil {
dbHost = settings.Host + ":" + fmt.Sprintf("%d", settings.Port)
dbUser = settings.User
dbName = settings.Database
}
// Get sync times // Get sync times
lastPricelistSync := h.localDB.GetLastSyncTime() lastPricelistSync := h.localDB.GetLastSyncTime()
// Get MariaDB counts (if online)
var lotCount, lotLogCount int64
if isOnline {
if mariaDB, err := h.connMgr.GetDB(); err == nil {
mariaDB.Table("lot").Count(&lotCount)
mariaDB.Table("lot_log").Count(&lotLogCount)
}
}
// Get local counts
configCount := h.localDB.CountConfigurations()
projectCount := h.localDB.CountProjects()
// Get error count (only changes with LastError != "") // Get error count (only changes with LastError != "")
errorCount := int(h.localDB.CountErroredChanges()) errorCount := int(h.localDB.CountErroredChanges())
// Get recent errors (last 10) // Get pending changes
changes, err := h.localDB.GetPendingChanges() changes, err := h.localDB.GetPendingChanges()
if err != nil { if err != nil {
slog.Error("failed to get pending changes for sync info", "error", err) slog.Error("failed to get pending changes for sync info", "error", err)
// Even if we can't get changes, we can still return the error count changes = []localdb.PendingChange{}
c.JSON(http.StatusOK, SyncInfoResponse{
LastSyncAt: lastPricelistSync,
IsOnline: isOnline,
ErrorCount: errorCount,
Errors: []SyncError{}, // Return empty errors list
})
return
} }
var errors []SyncError var syncErrors []SyncError
for _, change := range changes { for _, change := range changes {
// Check if there's a last error and it's not empty
if change.LastError != "" { if change.LastError != "" {
errors = append(errors, SyncError{ syncErrors = append(syncErrors, SyncError{
Timestamp: change.CreatedAt, Timestamp: change.CreatedAt,
Message: change.LastError, Message: change.LastError,
}) })
@@ -352,15 +512,63 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
} }
// Limit to last 10 errors // Limit to last 10 errors
if len(errors) > 10 { if len(syncErrors) > 10 {
errors = errors[:10] syncErrors = syncErrors[:10]
} }
readiness := h.getReadinessCached(10 * time.Second)
c.JSON(http.StatusOK, SyncInfoResponse{ c.JSON(http.StatusOK, SyncInfoResponse{
LastSyncAt: lastPricelistSync, DBHost: dbHost,
IsOnline: isOnline, DBUser: dbUser,
ErrorCount: errorCount, DBName: dbName,
Errors: errors, IsOnline: isOnline,
LastSyncAt: lastPricelistSync,
LotCount: lotCount,
LotLogCount: lotLogCount,
ConfigCount: configCount,
ProjectCount: projectCount,
PendingChanges: changes,
ErrorCount: errorCount,
Errors: syncErrors,
Readiness: readiness,
})
}
// GetUsersStatus returns last sync timestamps for users with sync heartbeats.
// GET /api/sync/users-status
func (h *SyncHandler) GetUsersStatus(c *gin.Context) {
threshold := time.Duration(float64(h.autoSyncInterval) * h.onlineGraceFactor)
isOnline := h.checkOnline()
if !isOnline {
c.JSON(http.StatusOK, SyncUsersStatusResponse{
IsOnline: false,
AutoSyncIntervalSeconds: int64(h.autoSyncInterval.Seconds()),
OnlineThresholdSeconds: int64(threshold.Seconds()),
GeneratedAt: time.Now().UTC(),
Users: []sync.UserSyncStatus{},
})
return
}
// Keep current client heartbeat fresh so app version is available in the table.
h.syncService.RecordSyncHeartbeat()
users, err := h.syncService.ListUserSyncStatuses(threshold)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}
c.JSON(http.StatusOK, SyncUsersStatusResponse{
IsOnline: true,
AutoSyncIntervalSeconds: int64(h.autoSyncInterval.Seconds()),
OnlineThresholdSeconds: int64(threshold.Seconds()),
GeneratedAt: time.Now().UTC(),
Users: users,
}) })
} }
@@ -380,12 +588,21 @@ func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
// Get pending count // Get pending count
pendingCount := h.localDB.GetPendingCount() pendingCount := h.localDB.GetPendingCount()
readiness := h.getReadinessCached(10 * time.Second)
isBlocked := readiness != nil && readiness.Blocked
slog.Debug("rendering sync status", "is_offline", isOffline, "pending_count", pendingCount) slog.Debug("rendering sync status", "is_offline", isOffline, "pending_count", pendingCount, "sync_blocked", isBlocked)
data := gin.H{ data := gin.H{
"IsOffline": isOffline, "IsOffline": isOffline,
"PendingCount": pendingCount, "PendingCount": pendingCount,
"IsBlocked": isBlocked,
"BlockedReason": func() string {
if readiness == nil {
return ""
}
return readiness.ReasonText
}(),
} }
c.Header("Content-Type", "text/html; charset=utf-8") c.Header("Content-Type", "text/html; charset=utf-8")
@@ -394,3 +611,24 @@ func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
c.String(http.StatusInternalServerError, "Template error: "+err.Error()) c.String(http.StatusInternalServerError, "Template error: "+err.Error())
} }
} }
func (h *SyncHandler) getReadinessCached(maxAge time.Duration) *sync.SyncReadiness {
h.readinessMu.Lock()
if h.readinessCached != nil && time.Since(h.readinessCachedAt) < maxAge {
cached := *h.readinessCached
h.readinessMu.Unlock()
return &cached
}
h.readinessMu.Unlock()
readiness, err := h.syncService.GetReadiness()
if err != nil && readiness == nil {
return nil
}
h.readinessMu.Lock()
h.readinessCached = readiness
h.readinessCachedAt = time.Now()
h.readinessMu.Unlock()
return readiness
}

View File

@@ -67,7 +67,7 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
} }
// 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", "pricelists.html", "pricelist_detail.html"}
for _, page := range simplePages { for _, page := range simplePages {
pagePath := filepath.Join(templatesPath, page) pagePath := filepath.Join(templatesPath, page)
var tmpl *template.Template var tmpl *template.Template
@@ -186,8 +186,15 @@ 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) AdminPricing(c *gin.Context) { func (h *WebHandler) Projects(c *gin.Context) {
h.render(c, "admin_pricing.html", gin.H{"ActivePage": "admin"}) 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) Pricelists(c *gin.Context) { func (h *WebHandler) Pricelists(c *gin.Context) {

View File

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

View File

@@ -19,6 +19,7 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
local := &LocalConfiguration{ local := &LocalConfiguration{
UUID: cfg.UUID, UUID: cfg.UUID,
ProjectUUID: cfg.ProjectUUID,
IsActive: true, IsActive: true,
Name: cfg.Name, Name: cfg.Name,
Items: items, Items: items,
@@ -27,11 +28,13 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
Notes: cfg.Notes, Notes: cfg.Notes,
IsTemplate: cfg.IsTemplate, IsTemplate: cfg.IsTemplate,
ServerCount: cfg.ServerCount, ServerCount: cfg.ServerCount,
PricelistID: cfg.PricelistID,
OnlyInStock: cfg.OnlyInStock,
PriceUpdatedAt: cfg.PriceUpdatedAt, PriceUpdatedAt: cfg.PriceUpdatedAt,
CreatedAt: cfg.CreatedAt, CreatedAt: cfg.CreatedAt,
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
SyncStatus: "pending", SyncStatus: "pending",
OriginalUserID: cfg.UserID, OriginalUserID: derefUint(cfg.UserID),
OriginalUsername: cfg.OwnerUsername, OriginalUsername: cfg.OwnerUsername,
} }
@@ -60,8 +63,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, OwnerUsername: local.OriginalUsername,
ProjectUUID: local.ProjectUUID,
Name: local.Name, Name: local.Name,
Items: items, Items: items,
TotalPrice: local.TotalPrice, TotalPrice: local.TotalPrice,
@@ -69,6 +72,8 @@ 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,
OnlyInStock: local.OnlyInStock,
PriceUpdatedAt: local.PriceUpdatedAt, PriceUpdatedAt: local.PriceUpdatedAt,
CreatedAt: local.CreatedAt, CreatedAt: local.CreatedAt,
} }
@@ -76,10 +81,57 @@ 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,
TrackerURL: project.TrackerURL,
IsActive: project.IsActive,
IsSystem: project.IsSystem,
CreatedAt: project.CreatedAt,
UpdatedAt: project.UpdatedAt,
SyncStatus: "pending",
}
if project.ID > 0 {
serverID := project.ID
local.ServerID = &serverID
}
return local
}
func LocalToProject(local *LocalProject) *models.Project {
project := &models.Project{
UUID: local.UUID,
OwnerUsername: local.OwnerUsername,
Name: local.Name,
TrackerURL: local.TrackerURL,
IsActive: local.IsActive,
IsSystem: local.IsSystem,
CreatedAt: local.CreatedAt,
UpdatedAt: local.UpdatedAt,
}
if local.ServerID != nil {
project.ID = *local.ServerID
}
return project
}
// PricelistToLocal converts models.Pricelist to LocalPricelist // PricelistToLocal converts models.Pricelist to LocalPricelist
func PricelistToLocal(pl *models.Pricelist) *LocalPricelist { func PricelistToLocal(pl *models.Pricelist) *LocalPricelist {
name := pl.Notification name := pl.Notification
@@ -89,6 +141,7 @@ func PricelistToLocal(pl *models.Pricelist) *LocalPricelist {
return &LocalPricelist{ return &LocalPricelist{
ServerID: pl.ID, ServerID: pl.ID,
Source: pl.Source,
Version: pl.Version, Version: pl.Version,
Name: name, Name: name,
CreatedAt: pl.CreatedAt, CreatedAt: pl.CreatedAt,
@@ -101,6 +154,7 @@ func PricelistToLocal(pl *models.Pricelist) *LocalPricelist {
func LocalToPricelist(local *LocalPricelist) *models.Pricelist { func LocalToPricelist(local *LocalPricelist) *models.Pricelist {
return &models.Pricelist{ return &models.Pricelist{
ID: local.ServerID, ID: local.ServerID,
Source: local.Source,
Version: local.Version, Version: local.Version,
Notification: local.Name, Notification: local.Name,
CreatedAt: local.CreatedAt, CreatedAt: local.CreatedAt,
@@ -110,20 +164,28 @@ func LocalToPricelist(local *LocalPricelist) *models.Pricelist {
// PricelistItemToLocal converts models.PricelistItem to LocalPricelistItem // PricelistItemToLocal converts models.PricelistItem to LocalPricelistItem
func PricelistItemToLocal(item *models.PricelistItem, localPricelistID uint) *LocalPricelistItem { func PricelistItemToLocal(item *models.PricelistItem, localPricelistID uint) *LocalPricelistItem {
partnumbers := make(LocalStringList, 0, len(item.Partnumbers))
partnumbers = append(partnumbers, item.Partnumbers...)
return &LocalPricelistItem{ return &LocalPricelistItem{
PricelistID: localPricelistID, PricelistID: localPricelistID,
LotName: item.LotName, LotName: item.LotName,
Price: item.Price, Price: item.Price,
AvailableQty: item.AvailableQty,
Partnumbers: partnumbers,
} }
} }
// LocalToPricelistItem converts LocalPricelistItem to models.PricelistItem // LocalToPricelistItem converts LocalPricelistItem to models.PricelistItem
func LocalToPricelistItem(local *LocalPricelistItem, serverPricelistID uint) *models.PricelistItem { func LocalToPricelistItem(local *LocalPricelistItem, serverPricelistID uint) *models.PricelistItem {
partnumbers := make([]string, 0, len(local.Partnumbers))
partnumbers = append(partnumbers, local.Partnumbers...)
return &models.PricelistItem{ return &models.PricelistItem{
ID: local.ID, ID: local.ID,
PricelistID: serverPricelistID, PricelistID: serverPricelistID,
LotName: local.LotName, LotName: local.LotName,
Price: local.Price, Price: local.Price,
AvailableQty: local.AvailableQty,
Partnumbers: partnumbers,
} }
} }

View File

@@ -3,6 +3,7 @@ package localdb
import ( import (
"path/filepath" "path/filepath"
"testing" "testing"
"time"
) )
func TestRunLocalMigrationsBackfillsExistingConfigurations(t *testing.T) { func TestRunLocalMigrationsBackfillsExistingConfigurations(t *testing.T) {
@@ -70,3 +71,57 @@ func TestRunLocalMigrationsBackfillsExistingConfigurations(t *testing.T) {
t.Fatalf("expected local migrations to be recorded") t.Fatalf("expected local migrations to be recorded")
} }
} }
func TestRunLocalMigrationsFixesPricelistVersionUniqueIndex(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "pricelist_index_fix.db")
local, err := New(dbPath)
if err != nil {
t.Fatalf("open localdb: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
if err := local.SaveLocalPricelist(&LocalPricelist{
ServerID: 10,
Version: "2026-02-06-001",
Name: "v1",
CreatedAt: time.Now().Add(-time.Hour),
SyncedAt: time.Now().Add(-time.Hour),
}); err != nil {
t.Fatalf("save first pricelist: %v", err)
}
if err := local.DB().Exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_local_pricelists_version_legacy
ON local_pricelists(version)
`).Error; err != nil {
t.Fatalf("create legacy unique version index: %v", err)
}
if err := local.DB().Where("id = ?", "2026_02_06_pricelist_index_fix").
Delete(&LocalSchemaMigration{}).Error; err != nil {
t.Fatalf("delete migration record: %v", err)
}
if err := runLocalMigrations(local.DB()); err != nil {
t.Fatalf("rerun local migrations: %v", err)
}
if err := local.SaveLocalPricelist(&LocalPricelist{
ServerID: 11,
Version: "2026-02-06-001",
Name: "v1-duplicate-version",
CreatedAt: time.Now(),
SyncedAt: time.Now(),
}); err != nil {
t.Fatalf("save second pricelist with duplicate version: %v", err)
}
var count int64
if err := local.DB().Model(&LocalPricelist{}).Count(&count).Error; err != nil {
t.Fatalf("count pricelists: %v", err)
}
if count != 2 {
t.Fatalf("expected 2 pricelists, got %d", count)
}
}

View File

@@ -1,15 +1,22 @@
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"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
mysqlDriver "github.com/go-sql-driver/mysql"
uuidpkg "github.com/google/uuid" uuidpkg "github.com/google/uuid"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
) )
@@ -52,12 +59,15 @@ 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{}, &LocalConfigurationVersion{},
&LocalPricelist{}, &LocalPricelist{},
&LocalPricelistItem{}, &LocalPricelistItem{},
&LocalComponent{}, &LocalComponent{},
&AppSetting{}, &AppSetting{},
&LocalRemoteMigrationApplied{},
&LocalSyncGuardState{},
&PendingChange{}, &PendingChange{},
); err != nil { ); err != nil {
return nil, fmt.Errorf("migrating sqlite database: %w", err) return nil, fmt.Errorf("migrating sqlite database: %w", err)
@@ -137,19 +147,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
@@ -177,6 +191,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
@@ -196,6 +420,37 @@ func (l *LocalDB) GetConfigurationByUUID(uuid string) (*LocalConfiguration, erro
return &config, err return &config, err
} }
// ListConfigurationsWithFilters returns configurations with DB-level filtering and pagination.
func (l *LocalDB) ListConfigurationsWithFilters(status string, search string, offset, limit int) ([]LocalConfiguration, int64, error) {
query := l.db.Model(&LocalConfiguration{})
switch status {
case "active":
query = query.Where("is_active = ?", true)
case "archived":
query = query.Where("is_active = ?", false)
case "all", "":
// no-op
default:
query = query.Where("is_active = ?", true)
}
search = strings.TrimSpace(search)
if search != "" {
query = query.Where("LOWER(name) LIKE ?", "%"+strings.ToLower(search)+"%")
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var configs []LocalConfiguration
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&configs).Error; err != nil {
return nil, 0, err
}
return configs, total, nil
}
// 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.DeactivateConfiguration(uuid) return l.DeactivateConfiguration(uuid)
@@ -238,6 +493,7 @@ func (l *LocalDB) DeactivateConfiguration(uuid string) error {
VersionNo: maxVersion + 1, VersionNo: maxVersion + 1,
Data: snapshot, Data: snapshot,
ChangeNote: &note, ChangeNote: &note,
AppVersion: appmeta.Version(),
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
if err := tx.Create(version).Error; err != nil { if err := tx.Create(version).Error; err != nil {
@@ -260,6 +516,13 @@ func (l *LocalDB) CountConfigurations() int64 {
return count return count
} }
// CountProjects returns the number of local projects
func (l *LocalDB) CountProjects() int64 {
var count int64
l.db.Model(&LocalProject{}).Count(&count)
return count
}
// Pricelist methods // Pricelist methods
// GetLastSyncTime returns the last sync timestamp // GetLastSyncTime returns the last sync timestamp
@@ -300,7 +563,16 @@ func (l *LocalDB) CountLocalPricelists() int64 {
// GetLatestLocalPricelist returns the most recently synced pricelist // GetLatestLocalPricelist returns the most recently synced pricelist
func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) { func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
var pricelist LocalPricelist var pricelist LocalPricelist
if err := l.db.Order("created_at DESC").First(&pricelist).Error; err != nil { if err := l.db.Where("source = ?", "estimate").Order("created_at DESC").First(&pricelist).Error; err != nil {
return nil, err
}
return &pricelist, nil
}
// GetLatestLocalPricelistBySource returns the most recently synced pricelist for a source.
func (l *LocalDB) GetLatestLocalPricelistBySource(source string) (*LocalPricelist, error) {
var pricelist LocalPricelist
if err := l.db.Where("source = ?", source).Order("created_at DESC").First(&pricelist).Error; err != nil {
return nil, err return nil, err
} }
return &pricelist, nil return &pricelist, nil
@@ -315,6 +587,24 @@ 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 = ? AND source = ?", version, "estimate").First(&pricelist).Error; err != nil {
return nil, err
}
return &pricelist, nil
}
// GetLocalPricelistBySourceAndVersion returns a local pricelist by source and version string.
func (l *LocalDB) GetLocalPricelistBySourceAndVersion(source, version string) (*LocalPricelist, error) {
var pricelist LocalPricelist
if err := l.db.Where("source = ? AND version = ?", source, version).First(&pricelist).Error; err != nil {
return nil, err
}
return &pricelist, nil
}
// GetLocalPricelistByID returns a local pricelist by its local ID // 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
@@ -326,7 +616,17 @@ func (l *LocalDB) GetLocalPricelistByID(id uint) (*LocalPricelist, error) {
// SaveLocalPricelist saves a pricelist to local SQLite // SaveLocalPricelist saves a pricelist to local SQLite
func (l *LocalDB) SaveLocalPricelist(pricelist *LocalPricelist) error { func (l *LocalDB) SaveLocalPricelist(pricelist *LocalPricelist) error {
return l.db.Save(pricelist).Error return l.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "server_id"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"source": pricelist.Source,
"version": pricelist.Version,
"name": pricelist.Name,
"created_at": pricelist.CreatedAt,
"synced_at": pricelist.SyncedAt,
"is_used": pricelist.IsUsed,
}),
}).Create(pricelist).Error
} }
// GetLocalPricelists returns all local pricelists // GetLocalPricelists returns all local pricelists
@@ -390,6 +690,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
@@ -472,7 +791,85 @@ 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()
} }
// GetRemoteMigrationApplied returns a locally applied remote migration by ID.
func (l *LocalDB) GetRemoteMigrationApplied(id string) (*LocalRemoteMigrationApplied, error) {
var migration LocalRemoteMigrationApplied
if err := l.db.Where("id = ?", id).First(&migration).Error; err != nil {
return nil, err
}
return &migration, nil
}
// UpsertRemoteMigrationApplied writes applied migration metadata.
func (l *LocalDB) UpsertRemoteMigrationApplied(id, checksum, appVersion string, appliedAt time.Time) error {
record := &LocalRemoteMigrationApplied{
ID: id,
Checksum: checksum,
AppVersion: appVersion,
AppliedAt: appliedAt,
}
return l.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"checksum": checksum,
"app_version": appVersion,
"applied_at": appliedAt,
}),
}).Create(record).Error
}
// GetLatestAppliedRemoteMigrationID returns last applied remote migration id.
func (l *LocalDB) GetLatestAppliedRemoteMigrationID() (string, error) {
var record LocalRemoteMigrationApplied
if err := l.db.Order("applied_at DESC").First(&record).Error; err != nil {
return "", err
}
return record.ID, nil
}
// GetSyncGuardState returns the latest readiness guard state.
func (l *LocalDB) GetSyncGuardState() (*LocalSyncGuardState, error) {
var state LocalSyncGuardState
if err := l.db.Order("id DESC").First(&state).Error; err != nil {
return nil, err
}
return &state, nil
}
// SetSyncGuardState upserts readiness guard state (single-row logical table).
func (l *LocalDB) SetSyncGuardState(status, reasonCode, reasonText string, requiredMinAppVersion *string, checkedAt *time.Time) error {
state := &LocalSyncGuardState{
ID: 1,
Status: status,
ReasonCode: reasonCode,
ReasonText: reasonText,
RequiredMinAppVersion: requiredMinAppVersion,
LastCheckedAt: checkedAt,
}
return l.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"status": status,
"reason_code": reasonCode,
"reason_text": reasonText,
"required_min_app_version": requiredMinAppVersion,
"last_checked_at": checkedAt,
"updated_at": time.Now(),
}),
}).Create(state).Error
}

View File

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

View File

@@ -1,8 +1,10 @@
package localdb package localdb
import ( import (
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"strings"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@@ -36,6 +38,26 @@ var localMigrations = []localMigration{
name: "Ensure is_active defaults to true for existing configurations", name: "Ensure is_active defaults to true for existing configurations",
run: backfillConfigurationIsActive, run: backfillConfigurationIsActive,
}, },
{
id: "2026_02_06_projects_backfill",
name: "Create default projects and attach existing configurations",
run: backfillProjectsForConfigurations,
},
{
id: "2026_02_06_pricelist_backfill",
name: "Attach existing configurations to latest local pricelist and recalc usage",
run: backfillConfigurationPricelists,
},
{
id: "2026_02_06_pricelist_index_fix",
name: "Use unique server_id for local pricelists and allow duplicate versions",
run: fixLocalPricelistIndexes,
},
{
id: "2026_02_06_pricelist_source",
name: "Backfill source for local pricelists and create source indexes",
run: backfillLocalPricelistSource,
},
} }
func runLocalMigrations(db *gorm.DB) error { func runLocalMigrations(db *gorm.DB) error {
@@ -103,6 +125,7 @@ func backfillConfigurationVersions(tx *gorm.DB) error {
VersionNo: 1, VersionNo: 1,
Data: snapshot, Data: snapshot,
ChangeNote: &note, ChangeNote: &note,
AppVersion: "backfill",
CreatedAt: chooseNonZeroTime(cfg.CreatedAt, time.Now()), CreatedAt: chooseNonZeroTime(cfg.CreatedAt, time.Now()),
} }
if err := tx.Create(&version).Error; err != nil { if err := tx.Create(&version).Error; err != nil {
@@ -132,9 +155,164 @@ func backfillConfigurationIsActive(tx *gorm.DB) error {
return tx.Exec("UPDATE local_configurations SET is_active = 1 WHERE is_active IS NULL").Error return tx.Exec("UPDATE local_configurations SET is_active = 1 WHERE is_active IS NULL").Error
} }
func backfillProjectsForConfigurations(tx *gorm.DB) error {
var owners []string
if err := tx.Model(&LocalConfiguration{}).
Distinct("original_username").
Pluck("original_username", &owners).Error; err != nil {
return fmt.Errorf("load owners for projects backfill: %w", err)
}
for _, owner := range owners {
project, err := ensureDefaultProjectTx(tx, owner)
if err != nil {
return err
}
if err := tx.Model(&LocalConfiguration{}).
Where("original_username = ? AND (project_uuid IS NULL OR project_uuid = '')", owner).
Update("project_uuid", project.UUID).Error; err != nil {
return fmt.Errorf("assign default project for owner %s: %w", owner, err)
}
}
return nil
}
func ensureDefaultProjectTx(tx *gorm.DB, ownerUsername string) (*LocalProject, error) {
var project LocalProject
err := tx.Where("owner_username = ? AND is_system = ? AND name = ?", ownerUsername, true, "Без проекта").
First(&project).Error
if err == nil {
return &project, nil
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("load system project for %s: %w", ownerUsername, err)
}
now := time.Now()
project = LocalProject{
UUID: uuid.NewString(),
OwnerUsername: ownerUsername,
Name: "Без проекта",
IsActive: true,
IsSystem: true,
CreatedAt: now,
UpdatedAt: now,
SyncStatus: "pending",
}
if err := tx.Create(&project).Error; err != nil {
return nil, fmt.Errorf("create system project for %s: %w", ownerUsername, err)
}
return &project, nil
}
func backfillConfigurationPricelists(tx *gorm.DB) error {
var latest LocalPricelist
if err := tx.Where("source = ?", "estimate").Order("created_at DESC").First(&latest).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return fmt.Errorf("load latest local pricelist: %w", err)
}
if err := tx.Model(&LocalConfiguration{}).
Where("pricelist_id IS NULL").
Update("pricelist_id", latest.ServerID).Error; err != nil {
return fmt.Errorf("backfill configuration pricelist_id: %w", err)
}
if err := tx.Model(&LocalPricelist{}).Where("1 = 1").Update("is_used", false).Error; err != nil {
return fmt.Errorf("reset local pricelist usage flags: %w", err)
}
if err := tx.Exec(`
UPDATE local_pricelists
SET is_used = 1
WHERE server_id IN (
SELECT DISTINCT pricelist_id
FROM local_configurations
WHERE pricelist_id IS NOT NULL AND is_active = 1
)
`).Error; err != nil {
return fmt.Errorf("recalculate local pricelist usage flags: %w", err)
}
return nil
}
func chooseNonZeroTime(candidate time.Time, fallback time.Time) time.Time { func chooseNonZeroTime(candidate time.Time, fallback time.Time) time.Time {
if candidate.IsZero() { if candidate.IsZero() {
return fallback return fallback
} }
return candidate return candidate
} }
func fixLocalPricelistIndexes(tx *gorm.DB) error {
type indexRow struct {
Name string `gorm:"column:name"`
Unique int `gorm:"column:unique"`
}
var indexes []indexRow
if err := tx.Raw("PRAGMA index_list('local_pricelists')").Scan(&indexes).Error; err != nil {
return fmt.Errorf("list local_pricelists indexes: %w", err)
}
for _, idx := range indexes {
if idx.Unique == 0 {
continue
}
type indexInfoRow struct {
Name string `gorm:"column:name"`
}
var info []indexInfoRow
if err := tx.Raw(fmt.Sprintf("PRAGMA index_info('%s')", strings.ReplaceAll(idx.Name, "'", "''"))).Scan(&info).Error; err != nil {
return fmt.Errorf("load index info for %s: %w", idx.Name, err)
}
if len(info) != 1 || info[0].Name != "version" {
continue
}
quoted := strings.ReplaceAll(idx.Name, `"`, `""`)
if err := tx.Exec(fmt.Sprintf(`DROP INDEX IF EXISTS "%s"`, quoted)).Error; err != nil {
return fmt.Errorf("drop unique version index %s: %w", idx.Name, err)
}
}
if err := tx.Exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_local_pricelists_server_id
ON local_pricelists(server_id)
`).Error; err != nil {
return fmt.Errorf("ensure unique index local_pricelists(server_id): %w", err)
}
if err := tx.Exec(`
CREATE INDEX IF NOT EXISTS idx_local_pricelists_version
ON local_pricelists(version)
`).Error; err != nil {
return fmt.Errorf("ensure index local_pricelists(version): %w", err)
}
return nil
}
func backfillLocalPricelistSource(tx *gorm.DB) error {
if err := tx.Exec(`
UPDATE local_pricelists
SET source = 'estimate'
WHERE source IS NULL OR source = ''
`).Error; err != nil {
return fmt.Errorf("backfill local_pricelists.source: %w", err)
}
if err := tx.Exec(`
CREATE INDEX IF NOT EXISTS idx_local_pricelists_source_created_at
ON local_pricelists(source, created_at DESC)
`).Error; err != nil {
return fmt.Errorf("ensure idx_local_pricelists_source_created_at: %w", err)
}
return nil
}

View File

@@ -57,11 +57,36 @@ func (c LocalConfigItems) Total() float64 {
return total return total
} }
// LocalStringList is a JSON-encoded list of strings stored as TEXT in SQLite.
type LocalStringList []string
func (s LocalStringList) Value() (driver.Value, error) {
return json.Marshal(s)
}
func (s *LocalStringList) Scan(value interface{}) error {
if value == nil {
*s = make(LocalStringList, 0)
return nil
}
var bytes []byte
switch v := value.(type) {
case []byte:
bytes = v
case string:
bytes = []byte(v)
default:
return errors.New("type assertion failed for LocalStringList")
}
return json.Unmarshal(bytes, s)
}
// 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
ProjectUUID *string `gorm:"index" json:"project_uuid,omitempty"`
CurrentVersionID *string `gorm:"index" json:"current_version_id,omitempty"` CurrentVersionID *string `gorm:"index" json:"current_version_id,omitempty"`
IsActive bool `gorm:"default:true;index" json:"is_active"` IsActive bool `gorm:"default:true;index" json:"is_active"`
Name string `gorm:"not null" json:"name"` Name string `gorm:"not null" json:"name"`
@@ -71,6 +96,8 @@ type LocalConfiguration struct {
Notes string `json:"notes"` Notes string `json:"notes"`
IsTemplate bool `gorm:"default:false" json:"is_template"` IsTemplate bool `gorm:"default:false" json:"is_template"`
ServerCount int `gorm:"default:1" json:"server_count"` ServerCount int `gorm:"default:1" json:"server_count"`
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"` PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
@@ -86,6 +113,25 @@ 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"`
TrackerURL string `json:"tracker_url"`
IsActive bool `gorm:"default:true;index" json:"is_active"`
IsSystem bool `gorm:"default:false;index" json:"is_system"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
SyncedAt *time.Time `json:"synced_at,omitempty"`
SyncStatus string `gorm:"default:'local'" json:"sync_status"` // local/synced/pending
}
func (LocalProject) TableName() string {
return "local_projects"
}
// LocalConfigurationVersion stores immutable full snapshots for each configuration version // LocalConfigurationVersion stores immutable full snapshots for each configuration version
type LocalConfigurationVersion struct { type LocalConfigurationVersion struct {
ID string `gorm:"primaryKey" json:"id"` ID string `gorm:"primaryKey" json:"id"`
@@ -94,6 +140,7 @@ type LocalConfigurationVersion struct {
Data string `gorm:"type:text;not null" json:"data"` Data string `gorm:"type:text;not null" json:"data"`
ChangeNote *string `json:"change_note,omitempty"` ChangeNote *string `json:"change_note,omitempty"`
CreatedBy *string `json:"created_by,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"` 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"` Configuration *LocalConfiguration `gorm:"foreignKey:ConfigurationUUID;references:UUID" json:"configuration,omitempty"`
} }
@@ -105,10 +152,11 @@ func (LocalConfigurationVersion) TableName() string {
// 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;uniqueIndex" json:"server_id"` // ID on MariaDB server
Version string `gorm:"uniqueIndex;not null" json:"version"` Source string `gorm:"not null;default:'estimate';index:idx_local_pricelists_source_created_at,priority:1" json:"source"`
Version string `gorm:"not null;index" json:"version"`
Name string `json:"name"` Name string `json:"name"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `gorm:"index:idx_local_pricelists_source_created_at,priority:2,sort:desc" 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
} }
@@ -119,10 +167,12 @@ func (LocalPricelist) TableName() string {
// LocalPricelistItem stores pricelist items // LocalPricelistItem stores pricelist items
type LocalPricelistItem struct { type LocalPricelistItem struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"` ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
PricelistID uint `gorm:"not null;index" json:"pricelist_id"` PricelistID uint `gorm:"not null;index" json:"pricelist_id"`
LotName string `gorm:"not null" json:"lot_name"` LotName string `gorm:"not null" json:"lot_name"`
Price float64 `gorm:"not null" json:"price"` Price float64 `gorm:"not null" json:"price"`
AvailableQty *float64 `json:"available_qty,omitempty"`
Partnumbers LocalStringList `gorm:"type:text" json:"partnumbers,omitempty"`
} }
func (LocalPricelistItem) TableName() string { func (LocalPricelistItem) TableName() string {
@@ -143,6 +193,33 @@ func (LocalComponent) TableName() string {
return "local_components" return "local_components"
} }
// LocalRemoteMigrationApplied tracks remote SQLite migrations received from server and applied locally.
type LocalRemoteMigrationApplied struct {
ID string `gorm:"primaryKey;size:128" json:"id"`
Checksum string `gorm:"size:128;not null" json:"checksum"`
AppVersion string `gorm:"size:64" json:"app_version,omitempty"`
AppliedAt time.Time `gorm:"not null" json:"applied_at"`
}
func (LocalRemoteMigrationApplied) TableName() string {
return "local_remote_migrations_applied"
}
// LocalSyncGuardState stores latest sync readiness decision for UI and preflight checks.
type LocalSyncGuardState struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
Status string `gorm:"size:32;not null;index" json:"status"` // ready|blocked|unknown
ReasonCode string `gorm:"size:128" json:"reason_code,omitempty"`
ReasonText string `gorm:"type:text" json:"reason_text,omitempty"`
RequiredMinAppVersion *string `gorm:"size:64" json:"required_min_app_version,omitempty"`
LastCheckedAt *time.Time `json:"last_checked_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
func (LocalSyncGuardState) TableName() string {
return "local_sync_guard_state"
}
// PendingChange stores changes that need to be synced to the server // PendingChange stores changes that need to be synced to the server
type PendingChange struct { type PendingChange struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`

View File

@@ -12,6 +12,7 @@ func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
"id": localCfg.ID, "id": localCfg.ID,
"uuid": localCfg.UUID, "uuid": localCfg.UUID,
"server_id": localCfg.ServerID, "server_id": localCfg.ServerID,
"project_uuid": localCfg.ProjectUUID,
"current_version_id": localCfg.CurrentVersionID, "current_version_id": localCfg.CurrentVersionID,
"is_active": localCfg.IsActive, "is_active": localCfg.IsActive,
"name": localCfg.Name, "name": localCfg.Name,
@@ -21,6 +22,8 @@ func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
"notes": localCfg.Notes, "notes": localCfg.Notes,
"is_template": localCfg.IsTemplate, "is_template": localCfg.IsTemplate,
"server_count": localCfg.ServerCount, "server_count": localCfg.ServerCount,
"pricelist_id": localCfg.PricelistID,
"only_in_stock": localCfg.OnlyInStock,
"price_updated_at": localCfg.PriceUpdatedAt, "price_updated_at": localCfg.PriceUpdatedAt,
"created_at": localCfg.CreatedAt, "created_at": localCfg.CreatedAt,
"updated_at": localCfg.UpdatedAt, "updated_at": localCfg.UpdatedAt,
@@ -40,6 +43,7 @@ func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
// DecodeConfigurationSnapshot returns editable fields from one saved snapshot. // DecodeConfigurationSnapshot returns editable fields from one saved snapshot.
func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) { func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
var snapshot struct { var snapshot struct {
ProjectUUID *string `json:"project_uuid"`
IsActive *bool `json:"is_active"` IsActive *bool `json:"is_active"`
Name string `json:"name"` Name string `json:"name"`
Items LocalConfigItems `json:"items"` Items LocalConfigItems `json:"items"`
@@ -48,6 +52,8 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
Notes string `json:"notes"` Notes string `json:"notes"`
IsTemplate bool `json:"is_template"` IsTemplate bool `json:"is_template"`
ServerCount int `json:"server_count"` ServerCount int `json:"server_count"`
PricelistID *uint `json:"pricelist_id"`
OnlyInStock bool `json:"only_in_stock"`
PriceUpdatedAt *time.Time `json:"price_updated_at"` PriceUpdatedAt *time.Time `json:"price_updated_at"`
OriginalUserID uint `json:"original_user_id"` OriginalUserID uint `json:"original_user_id"`
OriginalUsername string `json:"original_username"` OriginalUsername string `json:"original_username"`
@@ -64,6 +70,7 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
return &LocalConfiguration{ return &LocalConfiguration{
IsActive: isActive, IsActive: isActive,
ProjectUUID: snapshot.ProjectUUID,
Name: snapshot.Name, Name: snapshot.Name,
Items: snapshot.Items, Items: snapshot.Items,
TotalPrice: snapshot.TotalPrice, TotalPrice: snapshot.TotalPrice,
@@ -71,6 +78,8 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
Notes: snapshot.Notes, Notes: snapshot.Notes,
IsTemplate: snapshot.IsTemplate, IsTemplate: snapshot.IsTemplate,
ServerCount: snapshot.ServerCount, ServerCount: snapshot.ServerCount,
PricelistID: snapshot.PricelistID,
OnlyInStock: snapshot.OnlyInStock,
PriceUpdatedAt: snapshot.PriceUpdatedAt, PriceUpdatedAt: snapshot.PriceUpdatedAt,
OriginalUserID: snapshot.OriginalUserID, OriginalUserID: snapshot.OriginalUserID,
OriginalUsername: snapshot.OriginalUsername, OriginalUsername: snapshot.OriginalUsername,

View File

@@ -0,0 +1,238 @@
package lotmatch
import (
"errors"
"regexp"
"sort"
"strings"
"git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/gorm"
)
var (
ErrResolveConflict = errors.New("multiple lot matches")
ErrResolveNotFound = errors.New("lot not found")
)
type LotResolver struct {
partnumberToLots map[string][]string
exactLots map[string]string
allLots []string
}
type MappingMatcher struct {
exact map[string][]string
exactLot map[string]string
wildcard []wildcardMapping
}
type wildcardMapping struct {
lotName string
re *regexp.Regexp
}
func NewLotResolverFromDB(db *gorm.DB) (*LotResolver, error) {
mappings, lots, err := loadMappingsAndLots(db)
if err != nil {
return nil, err
}
return NewLotResolver(mappings, lots), nil
}
func NewMappingMatcherFromDB(db *gorm.DB) (*MappingMatcher, error) {
mappings, lots, err := loadMappingsAndLots(db)
if err != nil {
return nil, err
}
return NewMappingMatcher(mappings, lots), nil
}
func NewLotResolver(mappings []models.LotPartnumber, lots []models.Lot) *LotResolver {
partnumberToLots := make(map[string][]string, len(mappings))
for _, m := range mappings {
pn := NormalizeKey(m.Partnumber)
lot := strings.TrimSpace(m.LotName)
if pn == "" || lot == "" {
continue
}
partnumberToLots[pn] = append(partnumberToLots[pn], lot)
}
for key := range partnumberToLots {
partnumberToLots[key] = uniqueCaseInsensitive(partnumberToLots[key])
}
exactLots := make(map[string]string, len(lots))
allLots := make([]string, 0, len(lots))
for _, l := range lots {
name := strings.TrimSpace(l.LotName)
if name == "" {
continue
}
exactLots[NormalizeKey(name)] = name
allLots = append(allLots, name)
}
sort.Slice(allLots, func(i, j int) bool {
li := len([]rune(allLots[i]))
lj := len([]rune(allLots[j]))
if li == lj {
return allLots[i] < allLots[j]
}
return li > lj
})
return &LotResolver{
partnumberToLots: partnumberToLots,
exactLots: exactLots,
allLots: allLots,
}
}
func NewMappingMatcher(mappings []models.LotPartnumber, lots []models.Lot) *MappingMatcher {
exact := make(map[string][]string, len(mappings))
wildcards := make([]wildcardMapping, 0, len(mappings))
for _, m := range mappings {
pn := NormalizeKey(m.Partnumber)
lot := strings.TrimSpace(m.LotName)
if pn == "" || lot == "" {
continue
}
if strings.Contains(pn, "*") {
pattern := "^" + regexp.QuoteMeta(pn) + "$"
pattern = strings.ReplaceAll(pattern, "\\*", ".*")
re, err := regexp.Compile(pattern)
if err != nil {
continue
}
wildcards = append(wildcards, wildcardMapping{lotName: lot, re: re})
continue
}
exact[pn] = append(exact[pn], lot)
}
for key := range exact {
exact[key] = uniqueCaseInsensitive(exact[key])
}
exactLot := make(map[string]string, len(lots))
for _, l := range lots {
name := strings.TrimSpace(l.LotName)
if name == "" {
continue
}
exactLot[NormalizeKey(name)] = name
}
return &MappingMatcher{
exact: exact,
exactLot: exactLot,
wildcard: wildcards,
}
}
func (r *LotResolver) Resolve(partnumber string) (string, string, error) {
key := NormalizeKey(partnumber)
if key == "" {
return "", "", ErrResolveNotFound
}
if mapped := r.partnumberToLots[key]; len(mapped) > 0 {
if len(mapped) == 1 {
return mapped[0], "mapping_table", nil
}
return "", "", ErrResolveConflict
}
if exact, ok := r.exactLots[key]; ok {
return exact, "article_exact", nil
}
best := ""
bestLen := -1
tie := false
for _, lot := range r.allLots {
lotKey := NormalizeKey(lot)
if lotKey == "" {
continue
}
if strings.HasPrefix(key, lotKey) {
l := len([]rune(lotKey))
if l > bestLen {
best = lot
bestLen = l
tie = false
} else if l == bestLen && !strings.EqualFold(best, lot) {
tie = true
}
}
}
if best == "" {
return "", "", ErrResolveNotFound
}
if tie {
return "", "", ErrResolveConflict
}
return best, "prefix", nil
}
func (m *MappingMatcher) MatchLots(partnumber string) []string {
if m == nil {
return nil
}
key := NormalizeKey(partnumber)
if key == "" {
return nil
}
lots := make([]string, 0, 2)
if exact := m.exact[key]; len(exact) > 0 {
lots = append(lots, exact...)
}
for _, wc := range m.wildcard {
if wc.re == nil || !wc.re.MatchString(key) {
continue
}
lots = append(lots, wc.lotName)
}
if lot, ok := m.exactLot[key]; ok && strings.TrimSpace(lot) != "" {
lots = append(lots, lot)
}
return uniqueCaseInsensitive(lots)
}
func NormalizeKey(v string) string {
s := strings.ToLower(strings.TrimSpace(v))
replacer := strings.NewReplacer(" ", "", "-", "", "_", "", ".", "", "/", "", "\\", "", "\"", "", "'", "", "(", "", ")", "")
return replacer.Replace(s)
}
func loadMappingsAndLots(db *gorm.DB) ([]models.LotPartnumber, []models.Lot, error) {
var mappings []models.LotPartnumber
if err := db.Find(&mappings).Error; err != nil {
return nil, nil, err
}
var lots []models.Lot
if err := db.Select("lot_name").Find(&lots).Error; err != nil {
return nil, nil, err
}
return mappings, lots, nil
}
func uniqueCaseInsensitive(values []string) []string {
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))
for _, v := range values {
trimmed := strings.TrimSpace(v)
if trimmed == "" {
continue
}
key := strings.ToLower(trimmed)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, trimmed)
}
sort.Slice(out, func(i, j int) bool {
return strings.ToLower(out[i]) < strings.ToLower(out[j])
})
return out
}

View File

@@ -0,0 +1,62 @@
package lotmatch
import (
"testing"
"git.mchus.pro/mchus/quoteforge/internal/models"
)
func TestLotResolverPrecedence(t *testing.T) {
resolver := NewLotResolver(
[]models.LotPartnumber{
{Partnumber: "PN-1", LotName: "LOT_A"},
},
[]models.Lot{
{LotName: "CPU_X_LONG"},
{LotName: "CPU_X"},
},
)
lot, by, err := resolver.Resolve("PN-1")
if err != nil || lot != "LOT_A" || by != "mapping_table" {
t.Fatalf("expected mapping_table LOT_A, got lot=%s by=%s err=%v", lot, by, err)
}
lot, by, err = resolver.Resolve("CPU_X")
if err != nil || lot != "CPU_X" || by != "article_exact" {
t.Fatalf("expected article_exact CPU_X, got lot=%s by=%s err=%v", lot, by, err)
}
lot, by, err = resolver.Resolve("CPU_X_LONG_001")
if err != nil || lot != "CPU_X_LONG" || by != "prefix" {
t.Fatalf("expected prefix CPU_X_LONG, got lot=%s by=%s err=%v", lot, by, err)
}
}
func TestMappingMatcherWildcardAndExactLot(t *testing.T) {
matcher := NewMappingMatcher(
[]models.LotPartnumber{
{Partnumber: "R750*", LotName: "SERVER_R750"},
{Partnumber: "HDD-01", LotName: "HDD_01"},
},
[]models.Lot{
{LotName: "MEM_DDR5_16G_4800"},
},
)
check := func(partnumber string, want string) {
t.Helper()
got := matcher.MatchLots(partnumber)
if len(got) != 1 || got[0] != want {
t.Fatalf("partnumber %s: expected [%s], got %#v", partnumber, want, got)
}
}
check("R750XD", "SERVER_R750")
check("HDD-01", "HDD_01")
check("MEM_DDR5_16G_4800", "MEM_DDR5_16G_4800")
if got := matcher.MatchLots("UNKNOWN"); len(got) != 0 {
t.Fatalf("expected no matches for UNKNOWN, got %#v", got)
}
}

View File

@@ -40,19 +40,26 @@ 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"` // Legacy owner field (kept for backward compatibility) UserID *uint `json:"user_id,omitempty"` // Legacy field, no longer required for ownership
OwnerUsername string `gorm:"size:100;not null;default:'';index" json:"owner_username"` OwnerUsername string `gorm:"size:100;not null;default:'';index" json:"owner_username"`
Name string `gorm:"size:200;not null" json:"name"` ProjectUUID *string `gorm:"size:36;index" json:"project_uuid,omitempty"`
Items ConfigItems `gorm:"type:json;not null" json:"items"` AppVersion string `gorm:"size:64" json:"app_version,omitempty"`
TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"` Name string `gorm:"size:200;not null" json:"name"`
CustomPrice *float64 `gorm:"type:decimal(12,2)" json:"custom_price"` Items ConfigItems `gorm:"type:json;not null" json:"items"`
Notes string `gorm:"type:text" json:"notes"` TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"`
IsTemplate bool `gorm:"default:false" json:"is_template"` CustomPrice *float64 `gorm:"type:decimal(12,2)" json:"custom_price"`
ServerCount int `gorm:"default:1" json:"server_count"` Notes string `gorm:"type:text" json:"notes"`
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"` IsTemplate bool `gorm:"default:false" json:"is_template"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` ServerCount int `gorm:"default:1" json:"server_count"`
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"`
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
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"`
} }

View File

@@ -37,3 +37,44 @@ type Supplier struct {
func (Supplier) TableName() string { func (Supplier) TableName() string {
return "supplier" return "supplier"
} }
// StockLog stores warehouse stock snapshots imported from external files.
type StockLog struct {
StockLogID uint `gorm:"column:stock_log_id;primaryKey;autoIncrement"`
Partnumber string `gorm:"column:partnumber;size:255;not null"`
Supplier *string `gorm:"column:supplier;size:255"`
Date time.Time `gorm:"column:date;type:date;not null"`
Price float64 `gorm:"column:price;not null"`
Quality *string `gorm:"column:quality;size:255"`
Comments *string `gorm:"column:comments;size:15000"`
Vendor *string `gorm:"column:vendor;size:255"`
Qty *float64 `gorm:"column:qty"`
}
func (StockLog) TableName() string {
return "stock_log"
}
// LotPartnumber maps external part numbers to internal lots.
type LotPartnumber struct {
Partnumber string `gorm:"column:partnumber;size:255;primaryKey" json:"partnumber"`
LotName string `gorm:"column:lot_name;size:255;primaryKey" json:"lot_name"`
Description *string `gorm:"column:description;size:10000" json:"description,omitempty"`
}
func (LotPartnumber) TableName() string {
return "lot_partnumbers"
}
// StockIgnoreRule contains import ignore pattern rules.
type StockIgnoreRule struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
Target string `gorm:"column:target;size:20;not null" json:"target"` // partnumber|description
MatchType string `gorm:"column:match_type;size:20;not null" json:"match_type"` // exact|prefix|suffix
Pattern string `gorm:"column:pattern;size:500;not null" json:"pattern"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
}
func (StockIgnoreRule) TableName() string {
return "stock_ignore_rules"
}

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
package models
import "time"
type Project struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
OwnerUsername string `gorm:"size:100;not null;index" json:"owner_username"`
Name string `gorm:"size:200;not null" json:"name"`
TrackerURL string `gorm:"size:500" json:"tracker_url"`
IsActive bool `gorm:"default:true;index" json:"is_active"`
IsSystem bool `gorm:"default:false;index" json:"is_system"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
}
func (Project) TableName() string {
return "qt_projects"
}

View File

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

View File

@@ -110,6 +110,10 @@ func (r *ComponentRepository) Update(component *models.LotMetadata) error {
return r.db.Save(component).Error 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
} }

View File

@@ -19,7 +19,7 @@ func (r *ConfigurationRepository) Create(config *models.Configuration) error {
func (r *ConfigurationRepository) GetByID(id uint) (*models.Configuration, error) { func (r *ConfigurationRepository) GetByID(id uint) (*models.Configuration, error) {
var config models.Configuration var config models.Configuration
err := r.db.Preload("User").First(&config, id).Error err := r.db.First(&config, id).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -28,7 +28,7 @@ func (r *ConfigurationRepository) GetByID(id uint) (*models.Configuration, error
func (r *ConfigurationRepository) GetByUUID(uuid string) (*models.Configuration, error) { func (r *ConfigurationRepository) GetByUUID(uuid string) (*models.Configuration, error) {
var config models.Configuration var config models.Configuration
err := r.db.Preload("User").Where("uuid = ?", uuid).First(&config).Error err := r.db.Where("uuid = ?", uuid).First(&config).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -47,12 +47,11 @@ func (r *ConfigurationRepository) ListByUser(ownerUsername string, offset, limit
var configs []models.Configuration var configs []models.Configuration
var total int64 var total int64
ownerScope := "owner_username = ? OR (COALESCE(owner_username, '') = '' AND user_id IN (SELECT id FROM qt_users WHERE username = ?))" ownerScope := "owner_username = ?"
r.db.Model(&models.Configuration{}).Where(ownerScope, ownerUsername, ownerUsername).Count(&total) r.db.Model(&models.Configuration{}).Where(ownerScope, ownerUsername).Count(&total)
err := r.db. err := r.db.
Preload("User"). Where(ownerScope, ownerUsername).
Where(ownerScope, ownerUsername, ownerUsername).
Order("created_at DESC"). Order("created_at DESC").
Offset(offset). Offset(offset).
Limit(limit). Limit(limit).
@@ -67,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).
@@ -84,7 +82,6 @@ func (r *ConfigurationRepository) ListAll(offset, limit int) ([]models.Configura
r.db.Model(&models.Configuration{}).Count(&total) r.db.Model(&models.Configuration{}).Count(&total)
err := r.db. err := r.db.
Preload("User").
Order("created_at DESC"). Order("created_at DESC").
Offset(offset). Offset(offset).
Limit(limit). Limit(limit).

View File

@@ -1,7 +1,9 @@
package repository package repository
import ( import (
"errors"
"fmt" "fmt"
"strconv"
"strings" "strings"
"time" "time"
@@ -19,13 +21,24 @@ func NewPricelistRepository(db *gorm.DB) *PricelistRepository {
// List returns pricelists with pagination // List returns pricelists with pagination
func (r *PricelistRepository) List(offset, limit int) ([]models.PricelistSummary, int64, error) { func (r *PricelistRepository) List(offset, limit int) ([]models.PricelistSummary, int64, error) {
return r.ListBySource("", offset, limit)
}
// ListBySource returns pricelists filtered by source when provided.
func (r *PricelistRepository) ListBySource(source string, offset, limit int) ([]models.PricelistSummary, int64, error) {
query := r.db.Model(&models.Pricelist{}).
Where("EXISTS (SELECT 1 FROM qt_pricelist_items WHERE qt_pricelist_items.pricelist_id = qt_pricelists.id)")
if source != "" {
query = query.Where("source = ?", source)
}
var total int64 var total int64
if err := r.db.Model(&models.Pricelist{}).Count(&total).Error; err != nil { if err := query.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("counting pricelists: %w", err) return nil, 0, fmt.Errorf("counting pricelists: %w", err)
} }
var pricelists []models.Pricelist var pricelists []models.Pricelist
if err := r.db.Order("created_at DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil { if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
return nil, 0, fmt.Errorf("listing pricelists: %w", err) return nil, 0, fmt.Errorf("listing pricelists: %w", err)
} }
@@ -34,13 +47,25 @@ func (r *PricelistRepository) List(offset, limit int) ([]models.PricelistSummary
// ListActive returns active pricelists with pagination. // ListActive returns active pricelists with pagination.
func (r *PricelistRepository) ListActive(offset, limit int) ([]models.PricelistSummary, int64, error) { func (r *PricelistRepository) ListActive(offset, limit int) ([]models.PricelistSummary, int64, error) {
return r.ListActiveBySource("", offset, limit)
}
// ListActiveBySource returns active pricelists filtered by source when provided.
func (r *PricelistRepository) ListActiveBySource(source string, offset, limit int) ([]models.PricelistSummary, int64, error) {
query := r.db.Model(&models.Pricelist{}).
Where("is_active = ?", true).
Where("EXISTS (SELECT 1 FROM qt_pricelist_items WHERE qt_pricelist_items.pricelist_id = qt_pricelists.id)")
if source != "" {
query = query.Where("source = ?", source)
}
var total int64 var total int64
if err := r.db.Model(&models.Pricelist{}).Where("is_active = ?", true).Count(&total).Error; err != nil { if err := query.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("counting active pricelists: %w", err) return nil, 0, fmt.Errorf("counting active pricelists: %w", err)
} }
var pricelists []models.Pricelist 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 { if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
return nil, 0, fmt.Errorf("listing active pricelists: %w", err) return nil, 0, fmt.Errorf("listing active pricelists: %w", err)
} }
@@ -62,15 +87,17 @@ func (r *PricelistRepository) toSummaries(pricelists []models.Pricelist) []model
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,
Source: pl.Source,
Version: pl.Version, Version: pl.Version,
Notification: pl.Notification, Notification: pl.Notification,
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,
} }
@@ -90,14 +117,22 @@ 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
} }
// GetByVersion returns a pricelist by version string // GetByVersion returns a pricelist by version string
func (r *PricelistRepository) GetByVersion(version string) (*models.Pricelist, error) { func (r *PricelistRepository) GetByVersion(version string) (*models.Pricelist, error) {
return r.GetBySourceAndVersion(string(models.PricelistSourceEstimate), version)
}
// GetBySourceAndVersion returns a pricelist by source/version.
func (r *PricelistRepository) GetBySourceAndVersion(source, version string) (*models.Pricelist, error) {
var pricelist models.Pricelist var pricelist models.Pricelist
if err := r.db.Where("version = ?", version).First(&pricelist).Error; err != nil { if err := r.db.Where("source = ? AND version = ?", source, version).First(&pricelist).Error; err != nil {
return nil, fmt.Errorf("getting pricelist by version: %w", err) return nil, fmt.Errorf("getting pricelist by version: %w", err)
} }
return &pricelist, nil return &pricelist, nil
@@ -105,8 +140,13 @@ func (r *PricelistRepository) GetByVersion(version string) (*models.Pricelist, e
// GetLatestActive returns the most recent active pricelist // GetLatestActive returns the most recent active pricelist
func (r *PricelistRepository) GetLatestActive() (*models.Pricelist, error) { func (r *PricelistRepository) GetLatestActive() (*models.Pricelist, error) {
return r.GetLatestActiveBySource(string(models.PricelistSourceEstimate))
}
// GetLatestActiveBySource returns the most recent active pricelist by source.
func (r *PricelistRepository) GetLatestActiveBySource(source string) (*models.Pricelist, error) {
var pricelist models.Pricelist var pricelist models.Pricelist
if err := r.db.Where("is_active = ?", true).Order("created_at DESC").First(&pricelist).Error; err != nil { if err := r.db.Where("is_active = ? AND source = ?", true, source).Order("created_at DESC").First(&pricelist).Error; err != nil {
return nil, fmt.Errorf("getting latest pricelist: %w", err) return nil, fmt.Errorf("getting latest pricelist: %w", err)
} }
return &pricelist, nil return &pricelist, nil
@@ -130,13 +170,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
@@ -206,18 +246,114 @@ func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, sear
return items, total, nil return items, total, nil
} }
// GenerateVersion generates a new version string in format YYYY-MM-DD-NNN // GetLotNames returns distinct lot names from pricelist items.
func (r *PricelistRepository) GenerateVersion() (string, error) { func (r *PricelistRepository) GetLotNames(pricelistID uint) ([]string, error) {
today := time.Now().Format("2006-01-02") var lotNames []string
if err := r.db.Model(&models.PricelistItem{}).
Where("pricelist_id = ?", pricelistID).
Distinct("lot_name").
Order("lot_name ASC").
Pluck("lot_name", &lotNames).Error; err != nil {
return nil, fmt.Errorf("listing pricelist lot names: %w", err)
}
return lotNames, nil
}
var count int64 // GetPriceForLot returns item price for a lot within a pricelist.
if err := r.db.Model(&models.Pricelist{}). func (r *PricelistRepository) GetPriceForLot(pricelistID uint, lotName string) (float64, error) {
Where("version LIKE ?", today+"%"). var item models.PricelistItem
Count(&count).Error; err != nil { if err := r.db.Where("pricelist_id = ? AND lot_name = ?", pricelistID, lotName).First(&item).Error; err != nil {
return "", fmt.Errorf("counting today's pricelists: %w", err) return 0, err
}
return item.Price, nil
}
// GetPricesForLots returns price map for given lots within a pricelist.
func (r *PricelistRepository) GetPricesForLots(pricelistID uint, lotNames []string) (map[string]float64, error) {
result := make(map[string]float64, len(lotNames))
if pricelistID == 0 || len(lotNames) == 0 {
return result, nil
} }
return fmt.Sprintf("%s-%03d", today, count+1), nil var rows []models.PricelistItem
if err := r.db.Select("lot_name, price").
Where("pricelist_id = ? AND lot_name IN ?", pricelistID, lotNames).
Find(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
if row.Price > 0 {
result[row.LotName] = row.Price
}
}
return result, nil
}
// SetActive toggles active flag on a pricelist.
func (r *PricelistRepository) SetActive(id uint, isActive bool) error {
return r.db.Model(&models.Pricelist{}).Where("id = ?", id).Update("is_active", isActive).Error
}
// GenerateVersion generates a new version string in format YYYY-MM-DD-NNN
func (r *PricelistRepository) GenerateVersion() (string, error) {
return r.GenerateVersionBySource(string(models.PricelistSourceEstimate))
}
// GenerateVersionBySource generates a new version string in format YYYY-MM-DD-NNN scoped by source.
func (r *PricelistRepository) GenerateVersionBySource(source string) (string, error) {
today := time.Now().Format("2006-01-02")
prefix := versionPrefixBySource(source)
var last models.Pricelist
err := r.db.Model(&models.Pricelist{}).
Select("version").
Where("source = ? AND version LIKE ?", source, prefix+"-"+today+"-%").
Order("version DESC").
Limit(1).
Take(&last).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Sprintf("%s-%s-001", prefix, today), nil
}
return "", fmt.Errorf("loading latest today's pricelist version: %w", err)
}
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-%s-%03d", prefix, today, n+1), nil
}
func versionPrefixBySource(source string) string {
switch models.NormalizePricelistSource(source) {
case models.PricelistSourceWarehouse:
return "S"
case models.PricelistSourceCompetitor:
return "B"
default:
return "E"
}
}
// GetPriceForLotBySource returns item price for a lot from latest active pricelist of source.
func (r *PricelistRepository) GetPriceForLotBySource(source, lotName string) (float64, uint, error) {
latest, err := r.GetLatestActiveBySource(source)
if err != nil {
return 0, 0, err
}
price, err := r.GetPriceForLot(latest.ID, lotName)
if err != nil {
return 0, 0, err
}
return price, latest.ID, nil
} }
// CanWrite checks if the current database user has INSERT permission on qt_pricelists // CanWrite checks if the current database user has INSERT permission on qt_pricelists
@@ -276,6 +412,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

View File

@@ -0,0 +1,140 @@
package repository
import (
"fmt"
"testing"
"time"
"git.mchus.pro/mchus/quoteforge/internal/models"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestGenerateVersion_FirstOfDay(t *testing.T) {
repo := newTestPricelistRepository(t)
version, err := repo.GenerateVersionBySource(string(models.PricelistSourceEstimate))
if err != nil {
t.Fatalf("GenerateVersionBySource returned error: %v", err)
}
today := time.Now().Format("2006-01-02")
want := fmt.Sprintf("E-%s-001", today)
if version != want {
t.Fatalf("expected %s, got %s", want, version)
}
}
func TestGenerateVersion_UsesMaxSuffixNotCount(t *testing.T) {
repo := newTestPricelistRepository(t)
today := time.Now().Format("2006-01-02")
seed := []models.Pricelist{
{Source: string(models.PricelistSourceEstimate), Version: fmt.Sprintf("E-%s-001", today), CreatedBy: "test", IsActive: true},
{Source: string(models.PricelistSourceEstimate), Version: fmt.Sprintf("E-%s-003", today), CreatedBy: "test", IsActive: true},
}
for _, pl := range seed {
if err := repo.Create(&pl); err != nil {
t.Fatalf("seed insert failed: %v", err)
}
}
version, err := repo.GenerateVersionBySource(string(models.PricelistSourceEstimate))
if err != nil {
t.Fatalf("GenerateVersionBySource returned error: %v", err)
}
want := fmt.Sprintf("E-%s-004", today)
if version != want {
t.Fatalf("expected %s, got %s", want, version)
}
}
func TestGenerateVersion_IsolatedBySource(t *testing.T) {
repo := newTestPricelistRepository(t)
today := time.Now().Format("2006-01-02")
seed := []models.Pricelist{
{Source: string(models.PricelistSourceEstimate), Version: fmt.Sprintf("E-%s-009", today), CreatedBy: "test", IsActive: true},
{Source: string(models.PricelistSourceWarehouse), Version: fmt.Sprintf("S-%s-002", today), CreatedBy: "test", IsActive: true},
}
for _, pl := range seed {
if err := repo.Create(&pl); err != nil {
t.Fatalf("seed insert failed: %v", err)
}
}
version, err := repo.GenerateVersionBySource(string(models.PricelistSourceWarehouse))
if err != nil {
t.Fatalf("GenerateVersionBySource returned error: %v", err)
}
want := fmt.Sprintf("S-%s-003", today)
if version != want {
t.Fatalf("expected %s, got %s", want, version)
}
}
func TestGetItems_WarehouseAvailableQtyUsesPrefixResolver(t *testing.T) {
repo := newTestPricelistRepository(t)
db := repo.db
warehouse := models.Pricelist{
Source: string(models.PricelistSourceWarehouse),
Version: "S-2026-02-07-001",
CreatedBy: "test",
IsActive: true,
}
if err := db.Create(&warehouse).Error; err != nil {
t.Fatalf("create pricelist: %v", err)
}
if err := db.Create(&models.PricelistItem{
PricelistID: warehouse.ID,
LotName: "SSD_NVME_03.2T",
Price: 100,
}).Error; err != nil {
t.Fatalf("create pricelist item: %v", err)
}
if err := db.Create(&models.Lot{LotName: "SSD_NVME_03.2T"}).Error; err != nil {
t.Fatalf("create lot: %v", err)
}
qty := 5.0
if err := db.Create(&models.StockLog{
Partnumber: "SSD_NVME_03.2T_GEN3_P4610",
Date: time.Now(),
Price: 200,
Qty: &qty,
}).Error; err != nil {
t.Fatalf("create stock log: %v", err)
}
items, total, err := repo.GetItems(warehouse.ID, 0, 20, "")
if err != nil {
t.Fatalf("GetItems: %v", err)
}
if total != 1 {
t.Fatalf("expected total=1, got %d", total)
}
if len(items) != 1 {
t.Fatalf("expected 1 item, got %d", len(items))
}
if items[0].AvailableQty == nil {
t.Fatalf("expected available qty to be set")
}
if *items[0].AvailableQty != 5 {
t.Fatalf("expected available qty=5, got %v", *items[0].AvailableQty)
}
}
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{}, &models.PricelistItem{}, &models.Lot{}, &models.LotPartnumber{}, &models.StockLog{}); err != nil {
t.Fatalf("migrate: %v", err)
}
return NewPricelistRepository(db)
}

View File

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

View File

@@ -1,199 +0,0 @@
package alerts
import (
"fmt"
"time"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
)
type Service struct {
alertRepo *repository.AlertRepository
componentRepo *repository.ComponentRepository
priceRepo *repository.PriceRepository
statsRepo *repository.StatsRepository
config config.AlertsConfig
pricingConfig config.PricingConfig
}
func NewService(
alertRepo *repository.AlertRepository,
componentRepo *repository.ComponentRepository,
priceRepo *repository.PriceRepository,
statsRepo *repository.StatsRepository,
alertCfg config.AlertsConfig,
pricingCfg config.PricingConfig,
) *Service {
return &Service{
alertRepo: alertRepo,
componentRepo: componentRepo,
priceRepo: priceRepo,
statsRepo: statsRepo,
config: alertCfg,
pricingConfig: pricingCfg,
}
}
func (s *Service) List(filter repository.AlertFilter, page, perPage int) ([]models.PricingAlert, int64, error) {
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 100 {
perPage = 20
}
offset := (page - 1) * perPage
return s.alertRepo.List(filter, offset, perPage)
}
func (s *Service) Acknowledge(id uint) error {
return s.alertRepo.UpdateStatus(id, models.AlertStatusAcknowledged)
}
func (s *Service) Resolve(id uint) error {
return s.alertRepo.UpdateStatus(id, models.AlertStatusResolved)
}
func (s *Service) Ignore(id uint) error {
return s.alertRepo.UpdateStatus(id, models.AlertStatusIgnored)
}
func (s *Service) GetNewAlertsCount() (int64, error) {
return s.alertRepo.CountByStatus(models.AlertStatusNew)
}
// CheckAndGenerateAlerts scans components and creates alerts
func (s *Service) CheckAndGenerateAlerts() error {
if !s.config.Enabled {
return nil
}
// Get top components by usage
topComponents, err := s.statsRepo.GetTopComponents(100)
if err != nil {
return err
}
for _, stats := range topComponents {
component, err := s.componentRepo.GetByLotName(stats.LotName)
if err != nil {
continue
}
// Check high demand + stale price
if err := s.checkHighDemandStalePrice(component, &stats); err != nil {
continue
}
// Check trending without price
if err := s.checkTrendingNoPrice(component, &stats); err != nil {
continue
}
// Check no recent quotes
if err := s.checkNoRecentQuotes(component, &stats); err != nil {
continue
}
}
return nil
}
func (s *Service) checkHighDemandStalePrice(comp *models.LotMetadata, stats *models.ComponentUsageStats) error {
// high_demand_stale_price: >= 5 quotes/month AND price > 60 days old
if stats.QuotesLast30d < s.config.HighDemandThreshold {
return nil
}
if comp.PriceUpdatedAt == nil {
return nil
}
daysSinceUpdate := int(time.Since(*comp.PriceUpdatedAt).Hours() / 24)
if daysSinceUpdate <= s.pricingConfig.FreshnessYellowDays {
return nil
}
// Check if alert already exists
exists, _ := s.alertRepo.ExistsByLotAndType(comp.LotName, models.AlertHighDemandStalePrice)
if exists {
return nil
}
alert := &models.PricingAlert{
LotName: comp.LotName,
AlertType: models.AlertHighDemandStalePrice,
Severity: models.SeverityCritical,
Message: fmt.Sprintf("Компонент %s: высокий спрос (%d КП/мес), но цена устарела (%d дней)", comp.LotName, stats.QuotesLast30d, daysSinceUpdate),
Details: models.AlertDetails{
"quotes_30d": stats.QuotesLast30d,
"days_since_update": daysSinceUpdate,
},
}
return s.alertRepo.Create(alert)
}
func (s *Service) checkTrendingNoPrice(comp *models.LotMetadata, stats *models.ComponentUsageStats) error {
// trending_no_price: trend > 50% AND no price
if stats.TrendDirection != models.TrendUp || stats.TrendPercent < float64(s.config.TrendingThresholdPercent) {
return nil
}
if comp.CurrentPrice != nil && *comp.CurrentPrice > 0 {
return nil
}
exists, _ := s.alertRepo.ExistsByLotAndType(comp.LotName, models.AlertTrendingNoPrice)
if exists {
return nil
}
alert := &models.PricingAlert{
LotName: comp.LotName,
AlertType: models.AlertTrendingNoPrice,
Severity: models.SeverityHigh,
Message: fmt.Sprintf("Компонент %s: рост спроса +%.0f%%, но цена не установлена", comp.LotName, stats.TrendPercent),
Details: models.AlertDetails{
"trend_percent": stats.TrendPercent,
},
}
return s.alertRepo.Create(alert)
}
func (s *Service) checkNoRecentQuotes(comp *models.LotMetadata, stats *models.ComponentUsageStats) error {
// no_recent_quotes: popular component, no supplier quotes > 90 days
if stats.QuotesLast30d < 3 {
return nil
}
quoteCount, err := s.priceRepo.GetQuoteCount(comp.LotName, s.pricingConfig.FreshnessRedDays)
if err != nil {
return err
}
if quoteCount > 0 {
return nil
}
exists, _ := s.alertRepo.ExistsByLotAndType(comp.LotName, models.AlertNoRecentQuotes)
if exists {
return nil
}
alert := &models.PricingAlert{
LotName: comp.LotName,
AlertType: models.AlertNoRecentQuotes,
Severity: models.SeverityMedium,
Message: fmt.Sprintf("Компонент %s: популярный (%d КП), но нет новых котировок >%d дней", comp.LotName, stats.QuotesLast30d, s.pricingConfig.FreshnessRedDays),
Details: models.AlertDetails{
"quotes_30d": stats.QuotesLast30d,
"no_quotes_days": s.pricingConfig.FreshnessRedDays,
},
}
return s.alertRepo.Create(alert)
}

View File

@@ -22,18 +22,24 @@ type ConfigurationGetter interface {
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,
} }
} }
@@ -41,13 +47,25 @@ func NewConfigurationService(
type CreateConfigRequest struct { type CreateConfigRequest struct {
Name string `json:"name"` Name string `json:"name"`
Items models.ConfigItems `json:"items"` Items models.ConfigItems `json:"items"`
ProjectUUID *string `json:"project_uuid,omitempty"`
CustomPrice *float64 `json:"custom_price"` CustomPrice *float64 `json:"custom_price"`
Notes string `json:"notes"` Notes string `json:"notes"`
IsTemplate bool `json:"is_template"` IsTemplate bool `json:"is_template"`
ServerCount int `json:"server_count"` ServerCount int `json:"server_count"`
PricelistID *uint `json:"pricelist_id,omitempty"`
OnlyInStock bool `json:"only_in_stock"`
} }
func (s *ConfigurationService) Create(ownerUsername string, 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
@@ -58,6 +76,7 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq
config := &models.Configuration{ config := &models.Configuration{
UUID: uuid.New().String(), UUID: uuid.New().String(),
OwnerUsername: ownerUsername, OwnerUsername: ownerUsername,
ProjectUUID: projectUUID,
Name: req.Name, Name: req.Name,
Items: req.Items, Items: req.Items,
TotalPrice: &total, TotalPrice: &total,
@@ -65,6 +84,8 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq
Notes: req.Notes, Notes: req.Notes,
IsTemplate: req.IsTemplate, IsTemplate: req.IsTemplate,
ServerCount: req.ServerCount, ServerCount: req.ServerCount,
PricelistID: pricelistID,
OnlyInStock: req.OnlyInStock,
} }
if err := s.configRepo.Create(config); err != nil { if err := s.configRepo.Create(config); err != nil {
@@ -101,6 +122,15 @@ func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *Cr
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 +139,15 @@ func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *Cr
} }
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
config.OnlyInStock = req.OnlyInStock
if err := s.configRepo.Update(config); err != nil { if err := s.configRepo.Update(config); err != nil {
return nil, err return nil, err
@@ -156,10 +189,21 @@ func (s *ConfigurationService) Rename(uuid string, ownerUsername string, newName
} }
func (s *ConfigurationService) Clone(configUUID string, ownerUsername string, newName string) (*models.Configuration, error) { func (s *ConfigurationService) Clone(configUUID string, ownerUsername string, newName string) (*models.Configuration, error) {
return s.CloneToProject(configUUID, ownerUsername, newName, nil)
}
func (s *ConfigurationService) CloneToProject(configUUID string, ownerUsername string, newName string, projectUUID *string) (*models.Configuration, error) {
original, err := s.GetByUUID(configUUID, ownerUsername) 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()
@@ -172,6 +216,7 @@ func (s *ConfigurationService) Clone(configUUID string, ownerUsername string, ne
clone := &models.Configuration{ clone := &models.Configuration{
UUID: uuid.New().String(), UUID: uuid.New().String(),
OwnerUsername: ownerUsername, OwnerUsername: ownerUsername,
ProjectUUID: resolvedProjectUUID,
Name: newName, Name: newName,
Items: original.Items, Items: original.Items,
TotalPrice: &total, TotalPrice: &total,
@@ -179,6 +224,8 @@ func (s *ConfigurationService) Clone(configUUID string, ownerUsername string, ne
Notes: original.Notes, Notes: original.Notes,
IsTemplate: false, // Clone is never a template IsTemplate: false, // Clone is never a template
ServerCount: original.ServerCount, ServerCount: original.ServerCount,
PricelistID: original.PricelistID,
OnlyInStock: original.OnlyInStock,
} }
if err := s.configRepo.Create(clone); err != nil { if err := s.configRepo.Create(clone); err != nil {
@@ -229,18 +276,30 @@ 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
config.OnlyInStock = req.OnlyInStock
if err := s.configRepo.Update(config); err != nil { if err := s.configRepo.Update(config); err != nil {
return nil, err return nil, err
@@ -275,10 +334,21 @@ 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, ownerUsername string) (*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 {
@@ -288,6 +358,7 @@ func (s *ConfigurationService) CloneNoAuth(configUUID string, newName string, ow
clone := &models.Configuration{ clone := &models.Configuration{
UUID: uuid.New().String(), UUID: uuid.New().String(),
OwnerUsername: ownerUsername, OwnerUsername: ownerUsername,
ProjectUUID: resolvedProjectUUID,
Name: newName, Name: newName,
Items: original.Items, Items: original.Items,
TotalPrice: &total, TotalPrice: &total,
@@ -295,6 +366,8 @@ func (s *ConfigurationService) CloneNoAuth(configUUID string, newName string, ow
Notes: original.Notes, Notes: original.Notes,
IsTemplate: false, IsTemplate: false,
ServerCount: original.ServerCount, ServerCount: original.ServerCount,
PricelistID: original.PricelistID,
OnlyInStock: original.OnlyInStock,
} }
if err := s.configRepo.Create(clone); err != nil { if err := s.configRepo.Create(clone); err != nil {
@@ -304,6 +377,43 @@ func (s *ConfigurationService) CloneNoAuth(configUUID string, newName string, ow
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 +421,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 +465,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
@@ -366,10 +501,32 @@ func (s *ConfigurationService) RefreshPrices(uuid string, ownerUsername string)
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 +552,9 @@ func (s *ConfigurationService) RefreshPrices(uuid string, ownerUsername string)
} }
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()

View File

@@ -7,6 +7,7 @@ import (
"strings" "strings"
"time" "time"
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
"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/services/sync" "git.mchus.pro/mchus/quoteforge/internal/services/sync"
@@ -54,6 +55,15 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
} }
} }
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 req.ServerCount > 1 { if req.ServerCount > 1 {
total *= float64(req.ServerCount) total *= float64(req.ServerCount)
@@ -62,6 +72,7 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
cfg := &models.Configuration{ cfg := &models.Configuration{
UUID: uuid.New().String(), UUID: uuid.New().String(),
OwnerUsername: ownerUsername, OwnerUsername: ownerUsername,
ProjectUUID: projectUUID,
Name: req.Name, Name: req.Name,
Items: req.Items, Items: req.Items,
TotalPrice: &total, TotalPrice: &total,
@@ -69,6 +80,8 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
Notes: req.Notes, Notes: req.Notes,
IsTemplate: req.IsTemplate, IsTemplate: req.IsTemplate,
ServerCount: req.ServerCount, ServerCount: req.ServerCount,
PricelistID: pricelistID,
OnlyInStock: req.OnlyInStock,
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
@@ -117,6 +130,18 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
return nil, ErrConfigForbidden return nil, ErrConfigForbidden
} }
projectUUID := localCfg.ProjectUUID
if req.ProjectUUID != nil {
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 req.ServerCount > 1 { if req.ServerCount > 1 {
total *= float64(req.ServerCount) total *= float64(req.ServerCount)
@@ -124,6 +149,7 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
// Update fields // Update fields
localCfg.Name = req.Name localCfg.Name = req.Name
localCfg.ProjectUUID = projectUUID
localCfg.Items = localdb.LocalConfigItems{} localCfg.Items = localdb.LocalConfigItems{}
for _, item := range req.Items { for _, item := range req.Items {
localCfg.Items = append(localCfg.Items, localdb.LocalConfigItem{ localCfg.Items = append(localCfg.Items, localdb.LocalConfigItem{
@@ -137,6 +163,8 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
localCfg.Notes = req.Notes localCfg.Notes = req.Notes
localCfg.IsTemplate = req.IsTemplate localCfg.IsTemplate = req.IsTemplate
localCfg.ServerCount = req.ServerCount localCfg.ServerCount = req.ServerCount
localCfg.PricelistID = pricelistID
localCfg.OnlyInStock = req.OnlyInStock
localCfg.UpdatedAt = time.Now() localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending" localCfg.SyncStatus = "pending"
@@ -209,10 +237,21 @@ func (s *LocalConfigurationService) Rename(uuid string, ownerUsername string, ne
// Clone clones a configuration // Clone clones a configuration
func (s *LocalConfigurationService) Clone(configUUID string, ownerUsername string, newName string) (*models.Configuration, error) { func (s *LocalConfigurationService) Clone(configUUID string, ownerUsername string, newName string) (*models.Configuration, error) {
return s.CloneToProject(configUUID, ownerUsername, newName, nil)
}
func (s *LocalConfigurationService) CloneToProject(configUUID string, ownerUsername string, newName string, projectUUID *string) (*models.Configuration, error) {
original, err := s.GetByUUID(configUUID, ownerUsername) 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
}
}
total := original.Items.Total() total := original.Items.Total()
if original.ServerCount > 1 { if original.ServerCount > 1 {
@@ -222,6 +261,7 @@ func (s *LocalConfigurationService) Clone(configUUID string, ownerUsername strin
clone := &models.Configuration{ clone := &models.Configuration{
UUID: uuid.New().String(), UUID: uuid.New().String(),
OwnerUsername: ownerUsername, OwnerUsername: ownerUsername,
ProjectUUID: resolvedProjectUUID,
Name: newName, Name: newName,
Items: original.Items, Items: original.Items,
TotalPrice: &total, TotalPrice: &total,
@@ -229,6 +269,8 @@ func (s *LocalConfigurationService) Clone(configUUID string, ownerUsername strin
Notes: original.Notes, Notes: original.Notes,
IsTemplate: false, IsTemplate: false,
ServerCount: original.ServerCount, ServerCount: original.ServerCount,
PricelistID: original.PricelistID,
OnlyInStock: original.OnlyInStock,
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
@@ -299,10 +341,28 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
return nil, ErrConfigForbidden return nil, ErrConfigForbidden
} }
// Refresh local pricelists when online and use latest active/local pricelist for recalculation.
if s.isOnline() {
_ = s.syncService.SyncPricelistsIfNeeded()
}
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
// Update prices for all items // Update prices for all items
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items)) updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
for i, item := range localCfg.Items { for i, item := range localCfg.Items {
// Get current component price from local cache if latestErr == nil && latestPricelist != nil {
price, err := s.localDB.GetLocalPriceForLot(latestPricelist.ID, item.LotName)
if err == nil && price > 0 {
updatedItems[i] = localdb.LocalConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: price,
}
continue
}
}
// Fallback to current component price from local cache
component, err := s.localDB.GetLocalComponent(item.LotName) component, err := s.localDB.GetLocalComponent(item.LotName)
if err != nil || component.CurrentPrice == nil { if err != nil || component.CurrentPrice == nil {
// Keep original item if component not found or no price available // Keep original item if component not found or no price available
@@ -328,6 +388,9 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
} }
localCfg.TotalPrice = &total localCfg.TotalPrice = &total
if latestErr == nil && latestPricelist != nil {
localCfg.PricelistID = &latestPricelist.ServerID
}
// Set price update timestamp and mark for sync // Set price update timestamp and mark for sync
now := time.Now() now := time.Now()
@@ -361,12 +424,25 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
return nil, ErrConfigNotFound return nil, ErrConfigNotFound
} }
projectUUID := localCfg.ProjectUUID
if req.ProjectUUID != nil {
projectUUID, err = s.resolveProjectUUID(localCfg.OriginalUsername, 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)
} }
localCfg.Name = req.Name localCfg.Name = req.Name
localCfg.ProjectUUID = projectUUID
localCfg.Items = localdb.LocalConfigItems{} localCfg.Items = localdb.LocalConfigItems{}
for _, item := range req.Items { for _, item := range req.Items {
localCfg.Items = append(localCfg.Items, localdb.LocalConfigItem{ localCfg.Items = append(localCfg.Items, localdb.LocalConfigItem{
@@ -380,6 +456,8 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
localCfg.Notes = req.Notes localCfg.Notes = req.Notes
localCfg.IsTemplate = req.IsTemplate localCfg.IsTemplate = req.IsTemplate
localCfg.ServerCount = req.ServerCount localCfg.ServerCount = req.ServerCount
localCfg.PricelistID = pricelistID
localCfg.OnlyInStock = req.OnlyInStock
localCfg.UpdatedAt = time.Now() localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending" localCfg.SyncStatus = "pending"
@@ -439,10 +517,21 @@ func (s *LocalConfigurationService) RenameNoAuth(uuid string, newName string) (*
// CloneNoAuth clones configuration without ownership check // CloneNoAuth clones configuration without ownership check
func (s *LocalConfigurationService) CloneNoAuth(configUUID string, newName string, ownerUsername string) (*models.Configuration, error) { func (s *LocalConfigurationService) CloneNoAuth(configUUID string, newName string, ownerUsername string) (*models.Configuration, error) {
return s.CloneNoAuthToProject(configUUID, newName, ownerUsername, nil)
}
func (s *LocalConfigurationService) CloneNoAuthToProject(configUUID string, newName string, ownerUsername string, projectUUID *string) (*models.Configuration, error) {
original, err := s.GetByUUIDNoAuth(configUUID) original, err := s.GetByUUIDNoAuth(configUUID)
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
}
}
total := original.Items.Total() total := original.Items.Total()
if original.ServerCount > 1 { if original.ServerCount > 1 {
@@ -452,6 +541,7 @@ func (s *LocalConfigurationService) CloneNoAuth(configUUID string, newName strin
clone := &models.Configuration{ clone := &models.Configuration{
UUID: uuid.New().String(), UUID: uuid.New().String(),
OwnerUsername: ownerUsername, OwnerUsername: ownerUsername,
ProjectUUID: resolvedProjectUUID,
Name: newName, Name: newName,
Items: original.Items, Items: original.Items,
TotalPrice: &total, TotalPrice: &total,
@@ -459,6 +549,8 @@ func (s *LocalConfigurationService) CloneNoAuth(configUUID string, newName strin
Notes: original.Notes, Notes: original.Notes,
IsTemplate: false, IsTemplate: false,
ServerCount: original.ServerCount, ServerCount: original.ServerCount,
PricelistID: original.PricelistID,
OnlyInStock: original.OnlyInStock,
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
@@ -470,29 +562,44 @@ func (s *LocalConfigurationService) CloneNoAuth(configUUID string, newName strin
return clone, nil return clone, nil
} }
// SetProjectNoAuth moves configuration to a different project without ownership check.
func (s *LocalConfigurationService) SetProjectNoAuth(uuid string, projectUUID string) (*models.Configuration, error) {
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
var resolved *string
trimmed := strings.TrimSpace(projectUUID)
if trimmed == "" {
resolved, err = s.resolveProjectUUID(localCfg.OriginalUsername, &projectUUID)
if err != nil {
return nil, err
}
} else {
project, getErr := s.localDB.GetProjectByUUID(trimmed)
if getErr != nil {
return nil, ErrProjectNotFound
}
if !project.IsActive {
return nil, errors.New("project is archived")
}
resolved = &project.UUID
}
localCfg.ProjectUUID = resolved
localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending"
return s.saveWithVersionAndPending(localCfg, "update", "")
}
// ListAll returns all configurations without user filter // ListAll returns all configurations without user filter
func (s *LocalConfigurationService) ListAll(page, perPage int) ([]models.Configuration, int64, error) { func (s *LocalConfigurationService) ListAll(page, perPage int) ([]models.Configuration, int64, error) {
return s.ListAllWithStatus(page, perPage, "active") return s.ListAllWithStatus(page, perPage, "active", "")
} }
// ListAllWithStatus returns configurations filtered by status: active|archived|all. // ListAllWithStatus returns configurations filtered by status: active|archived|all.
func (s *LocalConfigurationService) ListAllWithStatus(page, perPage int, status string) ([]models.Configuration, int64, error) { func (s *LocalConfigurationService) ListAllWithStatus(page, perPage int, status string, search string) ([]models.Configuration, int64, error) {
localConfigs, err := s.localDB.GetConfigurations()
if err != nil {
return nil, 0, err
}
configs := make([]models.Configuration, len(localConfigs))
configs = configs[:0]
for _, lc := range localConfigs {
if !matchesConfigStatus(lc.IsActive, status) {
continue
}
configs = append(configs, *localdb.LocalToConfiguration(&lc))
}
total := int64(len(configs))
// Apply pagination // Apply pagination
if page < 1 { if page < 1 {
page = 1 page = 1
@@ -501,17 +608,15 @@ func (s *LocalConfigurationService) ListAllWithStatus(page, perPage int, status
perPage = 20 perPage = 20
} }
offset := (page - 1) * perPage offset := (page - 1) * perPage
localConfigs, total, err := s.localDB.ListConfigurationsWithFilters(status, search, offset, perPage)
start := offset if err != nil {
if start > len(configs) { return nil, 0, err
start = len(configs)
} }
end := start + perPage configs := make([]models.Configuration, 0, len(localConfigs))
if end > len(configs) { for _, lc := range localConfigs {
end = len(configs) configs = append(configs, *localdb.LocalToConfiguration(&lc))
} }
return configs, total, nil
return configs[start:end], total, nil
} }
// ListTemplates returns all template configurations // ListTemplates returns all template configurations
@@ -562,10 +667,27 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
return nil, ErrConfigNotFound return nil, ErrConfigNotFound
} }
if s.isOnline() {
_ = s.syncService.SyncPricelistsIfNeeded()
}
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
// Update prices for all items // Update prices for all items
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items)) updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
for i, item := range localCfg.Items { for i, item := range localCfg.Items {
// Get current component price from local cache if latestErr == nil && latestPricelist != nil {
price, err := s.localDB.GetLocalPriceForLot(latestPricelist.ID, item.LotName)
if err == nil && price > 0 {
updatedItems[i] = localdb.LocalConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: price,
}
continue
}
}
// Fallback to current component price from local cache
component, err := s.localDB.GetLocalComponent(item.LotName) component, err := s.localDB.GetLocalComponent(item.LotName)
if err != nil || component.CurrentPrice == nil { if err != nil || component.CurrentPrice == nil {
// Keep original item if component not found or no price available // Keep original item if component not found or no price available
@@ -591,6 +713,9 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
} }
localCfg.TotalPrice = &total localCfg.TotalPrice = &total
if latestErr == nil && latestPricelist != nil {
localCfg.PricelistID = &latestPricelist.ServerID
}
// Set price update timestamp and mark for sync // Set price update timestamp and mark for sync
now := time.Now() now := time.Now()
@@ -737,6 +862,9 @@ func (s *LocalConfigurationService) createWithVersion(localCfg *localdb.LocalCon
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "create", version, createdBy); err != nil { if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "create", version, createdBy); err != nil {
return fmt.Errorf("enqueue create pending change: %w", err) return fmt.Errorf("enqueue create pending change: %w", err)
} }
if err := s.recalculateLocalPricelistUsageTx(tx); err != nil {
return fmt.Errorf("recalculate local pricelist usage: %w", err)
}
return nil return nil
}) })
@@ -776,6 +904,9 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, operation, version, createdBy); err != nil { if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, operation, version, createdBy); err != nil {
return fmt.Errorf("enqueue %s pending change: %w", operation, err) return fmt.Errorf("enqueue %s pending change: %w", operation, err)
} }
if err := s.recalculateLocalPricelistUsageTx(tx); err != nil {
return fmt.Errorf("recalculate local pricelist usage: %w", err)
}
return nil return nil
}) })
@@ -820,6 +951,7 @@ func (s *LocalConfigurationService) appendVersionTx(
Data: snapshot, Data: snapshot,
ChangeNote: &changeNote, ChangeNote: &changeNote,
CreatedBy: createdByPtr, CreatedBy: createdByPtr,
AppVersion: appmeta.Version(),
} }
if err := tx.Create(version).Error; err != nil { if err := tx.Create(version).Error; err != nil {
@@ -879,6 +1011,8 @@ func (s *LocalConfigurationService) rollbackToVersion(configurationUUID string,
current.Notes = rollbackData.Notes current.Notes = rollbackData.Notes
current.IsTemplate = rollbackData.IsTemplate current.IsTemplate = rollbackData.IsTemplate
current.ServerCount = rollbackData.ServerCount current.ServerCount = rollbackData.ServerCount
current.PricelistID = rollbackData.PricelistID
current.OnlyInStock = rollbackData.OnlyInStock
current.PriceUpdatedAt = rollbackData.PriceUpdatedAt current.PriceUpdatedAt = rollbackData.PriceUpdatedAt
current.UpdatedAt = time.Now() current.UpdatedAt = time.Now()
current.SyncStatus = "pending" current.SyncStatus = "pending"
@@ -915,6 +1049,7 @@ func (s *LocalConfigurationService) rollbackToVersion(configurationUUID string,
Data: target.Data, Data: target.Data,
ChangeNote: &changeNote, ChangeNote: &changeNote,
CreatedBy: stringPtrOrNil(userID), CreatedBy: stringPtrOrNil(userID),
AppVersion: appmeta.Version(),
} }
if err := tx.Create(version).Error; err != nil { if err := tx.Create(version).Error; err != nil {
@@ -935,6 +1070,9 @@ func (s *LocalConfigurationService) rollbackToVersion(configurationUUID string,
if err := s.enqueueConfigurationPendingChangeTx(tx, &current, "rollback", version, userID); err != nil { if err := s.enqueueConfigurationPendingChangeTx(tx, &current, "rollback", version, userID); err != nil {
return fmt.Errorf("enqueue rollback pending change: %w", err) return fmt.Errorf("enqueue rollback pending change: %w", err)
} }
if err := s.recalculateLocalPricelistUsageTx(tx); err != nil {
return fmt.Errorf("recalculate local pricelist usage: %w", err)
}
return nil return nil
}) })
@@ -957,6 +1095,8 @@ func (s *LocalConfigurationService) enqueueConfigurationPendingChangeTx(
EventID: uuid.New().String(), EventID: uuid.New().String(),
IdempotencyKey: fmt.Sprintf("%s:v%d:%s", localCfg.UUID, version.VersionNo, operation), IdempotencyKey: fmt.Sprintf("%s:v%d:%s", localCfg.UUID, version.VersionNo, operation),
ConfigurationUUID: localCfg.UUID, ConfigurationUUID: localCfg.UUID,
ProjectUUID: localCfg.ProjectUUID,
PricelistID: localCfg.PricelistID,
Operation: operation, Operation: operation,
CurrentVersionID: version.ID, CurrentVersionID: version.ID,
CurrentVersionNo: version.VersionNo, CurrentVersionNo: version.VersionNo,
@@ -990,6 +1130,21 @@ func (s *LocalConfigurationService) decodeConfigurationSnapshot(data string) (*l
return localdb.DecodeConfigurationSnapshot(data) return localdb.DecodeConfigurationSnapshot(data)
} }
func (s *LocalConfigurationService) recalculateLocalPricelistUsageTx(tx *gorm.DB) error {
if err := tx.Model(&localdb.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
}
func stringPtrOrNil(value string) *string { func stringPtrOrNil(value string) *string {
trimmed := strings.TrimSpace(value) trimmed := strings.TrimSpace(value)
if trimmed == "" { if trimmed == "" {
@@ -1010,3 +1165,50 @@ func matchesConfigStatus(isActive bool, status string) bool {
return isActive return isActive
} }
} }
func (s *LocalConfigurationService) resolveProjectUUID(ownerUsername string, projectUUID *string) (*string, error) {
if ownerUsername == "" {
ownerUsername = s.localDB.GetDBUser()
}
if projectUUID == nil || strings.TrimSpace(*projectUUID) == "" {
project, err := s.localDB.EnsureDefaultProject(ownerUsername)
if err != nil {
return nil, err
}
return &project.UUID, nil
}
requested := strings.TrimSpace(*projectUUID)
project, err := s.localDB.GetProjectByUUID(requested)
if err != nil {
return nil, ErrProjectNotFound
}
if !project.IsActive {
return nil, errors.New("project is archived")
}
return &project.UUID, nil
}
func (s *LocalConfigurationService) resolvePricelistID(pricelistID *uint) (*uint, error) {
if pricelistID != nil && *pricelistID > 0 {
if _, err := s.localDB.GetLocalPricelistByServerID(*pricelistID); err == nil {
return pricelistID, nil
}
if s.isOnline() {
if _, err := s.syncService.SyncPricelists(); err == nil {
if _, err := s.localDB.GetLocalPricelistByServerID(*pricelistID); err == nil {
return pricelistID, nil
}
}
}
return nil, fmt.Errorf("pricelist %d not available locally", *pricelistID)
}
latest, err := s.localDB.GetLatestLocalPricelist()
if err != nil {
return nil, nil
}
return &latest.ServerID, nil
}

View File

@@ -185,6 +185,48 @@ WHERE configuration_uuid = ?`, created.UUID).Scan(&c).Error; err != nil {
} }
} }
func TestUpdateNoAuthKeepsProjectWhenProjectUUIDOmitted(t *testing.T) {
service, local := newLocalConfigServiceForTest(t)
project := &localdb.LocalProject{
UUID: "project-keep",
OwnerUsername: "tester",
Name: "Keep Project",
IsActive: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
SyncStatus: "synced",
}
if err := local.SaveProject(project); err != nil {
t.Fatalf("save project: %v", err)
}
created, err := service.Create("tester", &CreateConfigRequest{
Name: "cfg",
ProjectUUID: &project.UUID,
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 100}},
ServerCount: 1,
})
if err != nil {
t.Fatalf("create config: %v", err)
}
if created.ProjectUUID == nil || *created.ProjectUUID != project.UUID {
t.Fatalf("expected created config project_uuid=%s", project.UUID)
}
updated, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
Name: "cfg-updated",
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 100}},
ServerCount: 1,
})
if err != nil {
t.Fatalf("update config without project_uuid: %v", err)
}
if updated.ProjectUUID == nil || *updated.ProjectUUID != project.UUID {
t.Fatalf("expected project_uuid to stay %s after update, got %+v", project.UUID, updated.ProjectUUID)
}
}
func newLocalConfigServiceForTest(t *testing.T) (*LocalConfigurationService, *localdb.LocalDB) { func newLocalConfigServiceForTest(t *testing.T) (*LocalConfigurationService, *localdb.LocalDB) {
t.Helper() t.Helper()

View File

@@ -1,187 +0,0 @@
package pricelist
import (
"fmt"
"log/slog"
"time"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"gorm.io/gorm"
)
type Service struct {
repo *repository.PricelistRepository
componentRepo *repository.ComponentRepository
db *gorm.DB
}
func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo *repository.ComponentRepository) *Service {
return &Service{
repo: repo,
componentRepo: componentRepo,
db: db,
}
}
// CreateFromCurrentPrices creates a new pricelist by taking a snapshot of current prices
func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist, error) {
if s.repo == nil || s.db == nil {
return nil, fmt.Errorf("offline mode: cannot create pricelists")
}
version, err := s.repo.GenerateVersion()
if err != nil {
return nil, fmt.Errorf("generating version: %w", err)
}
expiresAt := time.Now().AddDate(1, 0, 0) // +1 year
pricelist := &models.Pricelist{
Version: version,
CreatedBy: createdBy,
IsActive: true,
ExpiresAt: &expiresAt,
}
if err := s.repo.Create(pricelist); err != nil {
return nil, fmt.Errorf("creating pricelist: %w", err)
}
// Get all components with prices from qt_lot_metadata
var metadata []models.LotMetadata
if err := s.db.Where("current_price IS NOT NULL AND current_price > 0").Find(&metadata).Error; err != nil {
return nil, fmt.Errorf("getting lot metadata: %w", err)
}
// Create pricelist items with all price settings
items := make([]models.PricelistItem, 0, len(metadata))
for _, m := range metadata {
if m.CurrentPrice == nil || *m.CurrentPrice <= 0 {
continue
}
items = append(items, models.PricelistItem{
PricelistID: pricelist.ID,
LotName: m.LotName,
Price: *m.CurrentPrice,
PriceMethod: string(m.PriceMethod),
PricePeriodDays: m.PricePeriodDays,
PriceCoefficient: m.PriceCoefficient,
ManualPrice: m.ManualPrice,
MetaPrices: m.MetaPrices,
})
}
if err := s.repo.CreateItems(items); err != nil {
// Clean up the pricelist if items creation fails
s.repo.Delete(pricelist.ID)
return nil, fmt.Errorf("creating pricelist items: %w", err)
}
pricelist.ItemCount = len(items)
slog.Info("pricelist created",
"id", pricelist.ID,
"version", pricelist.Version,
"items", len(items),
"created_by", createdBy,
)
return pricelist, nil
}
// List returns pricelists with pagination
func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, error) {
// If no database connection (offline mode), return empty list
if s.repo == nil {
return []models.PricelistSummary{}, 0, nil
}
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 20
}
offset := (page - 1) * perPage
return s.repo.List(offset, perPage)
}
// GetByID returns a pricelist by ID
func (s *Service) GetByID(id uint) (*models.Pricelist, error) {
if s.repo == nil {
return nil, fmt.Errorf("offline mode: pricelist service not available")
}
return s.repo.GetByID(id)
}
// GetItems returns pricelist items with pagination
func (s *Service) GetItems(pricelistID uint, page, perPage int, search string) ([]models.PricelistItem, int64, error) {
if s.repo == nil {
return []models.PricelistItem{}, 0, nil
}
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 50
}
offset := (page - 1) * perPage
return s.repo.GetItems(pricelistID, offset, perPage, search)
}
// Delete deletes a pricelist by ID
func (s *Service) Delete(id uint) error {
if s.repo == nil {
return fmt.Errorf("offline mode: cannot delete pricelists")
}
return s.repo.Delete(id)
}
// CanWrite returns true if the user can create pricelists
func (s *Service) CanWrite() bool {
if s.repo == nil {
return false
}
return s.repo.CanWrite()
}
// CanWriteDebug returns write permission status with debug info
func (s *Service) CanWriteDebug() (bool, string) {
if s.repo == nil {
return false, "offline mode"
}
return s.repo.CanWriteDebug()
}
// GetLatestActive returns the most recent active pricelist
func (s *Service) GetLatestActive() (*models.Pricelist, error) {
if s.repo == nil {
return nil, fmt.Errorf("offline mode: pricelist service not available")
}
return s.repo.GetLatestActive()
}
// CleanupExpired deletes expired and unused pricelists
func (s *Service) CleanupExpired() (int, error) {
if s.repo == nil {
return 0, fmt.Errorf("offline mode: cleanup not available")
}
expired, err := s.repo.GetExpiredUnused()
if err != nil {
return 0, err
}
deleted := 0
for _, pl := range expired {
if err := s.repo.Delete(pl.ID); err != nil {
slog.Warn("failed to delete expired pricelist", "id", pl.ID, "error", err)
continue
}
deleted++
}
slog.Info("cleaned up expired pricelists", "deleted", deleted)
return deleted, nil
}

View File

@@ -1,121 +0,0 @@
package pricing
import (
"math"
"sort"
"time"
"git.mchus.pro/mchus/quoteforge/internal/repository"
)
// CalculateMedian returns the median of prices
func CalculateMedian(prices []float64) float64 {
if len(prices) == 0 {
return 0
}
sorted := make([]float64, len(prices))
copy(sorted, prices)
sort.Float64s(sorted)
n := len(sorted)
if n%2 == 0 {
return (sorted[n/2-1] + sorted[n/2]) / 2
}
return sorted[n/2]
}
// CalculateAverage returns the arithmetic mean of prices
func CalculateAverage(prices []float64) float64 {
if len(prices) == 0 {
return 0
}
var sum float64
for _, p := range prices {
sum += p
}
return sum / float64(len(prices))
}
// CalculateWeightedMedian calculates median with exponential decay weights
// More recent prices have higher weight
func CalculateWeightedMedian(points []repository.PricePoint, decayDays int) float64 {
if len(points) == 0 {
return 0
}
type weightedPrice struct {
price float64
weight float64
}
now := time.Now()
weighted := make([]weightedPrice, len(points))
var totalWeight float64
for i, p := range points {
daysSince := now.Sub(p.Date).Hours() / 24
// weight = e^(-days / decay_days)
weight := math.Exp(-daysSince / float64(decayDays))
weighted[i] = weightedPrice{price: p.Price, weight: weight}
totalWeight += weight
}
// Sort by price
sort.Slice(weighted, func(i, j int) bool {
return weighted[i].price < weighted[j].price
})
// Find weighted median
targetWeight := totalWeight / 2
var cumulativeWeight float64
for _, wp := range weighted {
cumulativeWeight += wp.weight
if cumulativeWeight >= targetWeight {
return wp.price
}
}
return weighted[len(weighted)-1].price
}
// CalculatePercentile calculates the nth percentile of prices
func CalculatePercentile(prices []float64, percentile float64) float64 {
if len(prices) == 0 {
return 0
}
sorted := make([]float64, len(prices))
copy(sorted, prices)
sort.Float64s(sorted)
index := (percentile / 100) * float64(len(sorted)-1)
lower := int(math.Floor(index))
upper := int(math.Ceil(index))
if lower == upper {
return sorted[lower]
}
fraction := index - float64(lower)
return sorted[lower]*(1-fraction) + sorted[upper]*fraction
}
// CalculateStdDev calculates standard deviation
func CalculateStdDev(prices []float64) float64 {
if len(prices) < 2 {
return 0
}
mean := CalculateAverage(prices)
var sumSquares float64
for _, p := range prices {
diff := p - mean
sumSquares += diff * diff
}
return math.Sqrt(sumSquares / float64(len(prices)-1))
}

View File

@@ -1,205 +0,0 @@
package pricing
import (
"time"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
)
type Service struct {
componentRepo *repository.ComponentRepository
priceRepo *repository.PriceRepository
config config.PricingConfig
}
func NewService(
componentRepo *repository.ComponentRepository,
priceRepo *repository.PriceRepository,
cfg config.PricingConfig,
) *Service {
return &Service{
componentRepo: componentRepo,
priceRepo: priceRepo,
config: cfg,
}
}
// GetEffectivePrice returns the current effective price for a component
// Priority: active override > calculated price > nil
func (s *Service) GetEffectivePrice(lotName string) (*float64, error) {
// Check for active override first
override, err := s.priceRepo.GetPriceOverride(lotName)
if err == nil && override != nil {
return &override.Price, nil
}
// Get component metadata
component, err := s.componentRepo.GetByLotName(lotName)
if err != nil {
return nil, err
}
return component.CurrentPrice, nil
}
// CalculatePrice calculates price using the specified method
func (s *Service) CalculatePrice(lotName string, method models.PriceMethod, periodDays int) (float64, error) {
if periodDays == 0 {
periodDays = s.config.DefaultPeriodDays
}
points, err := s.priceRepo.GetPriceHistory(lotName, periodDays)
if err != nil {
return 0, err
}
if len(points) == 0 {
return 0, nil
}
prices := make([]float64, len(points))
for i, p := range points {
prices[i] = p.Price
}
switch method {
case models.PriceMethodAverage:
return CalculateAverage(prices), nil
case models.PriceMethodWeightedMedian:
return CalculateWeightedMedian(points, periodDays), nil
case models.PriceMethodMedian:
fallthrough
default:
return CalculateMedian(prices), nil
}
}
// UpdateComponentPrice recalculates and updates the price for a component
func (s *Service) UpdateComponentPrice(lotName string) error {
component, err := s.componentRepo.GetByLotName(lotName)
if err != nil {
return err
}
price, err := s.CalculatePrice(lotName, component.PriceMethod, component.PricePeriodDays)
if err != nil {
return err
}
now := time.Now()
if price > 0 {
component.CurrentPrice = &price
component.PriceUpdatedAt = &now
}
return s.componentRepo.Update(component)
}
// SetManualPrice sets a manual price override
func (s *Service) SetManualPrice(lotName string, price float64, reason string, userID uint) error {
override := &models.PriceOverride{
LotName: lotName,
Price: price,
ValidFrom: time.Now(),
Reason: reason,
CreatedBy: userID,
}
return s.priceRepo.CreatePriceOverride(override)
}
// UpdatePriceMethod changes the pricing method for a component
func (s *Service) UpdatePriceMethod(lotName string, method models.PriceMethod, periodDays int) error {
component, err := s.componentRepo.GetByLotName(lotName)
if err != nil {
return err
}
component.PriceMethod = method
if periodDays > 0 {
component.PricePeriodDays = periodDays
}
if err := s.componentRepo.Update(component); err != nil {
return err
}
return s.UpdateComponentPrice(lotName)
}
// GetPriceStats returns statistics for a component's price history
func (s *Service) GetPriceStats(lotName string, periodDays int) (*PriceStats, error) {
if periodDays == 0 {
periodDays = s.config.DefaultPeriodDays
}
points, err := s.priceRepo.GetPriceHistory(lotName, periodDays)
if err != nil {
return nil, err
}
if len(points) == 0 {
return &PriceStats{QuoteCount: 0}, nil
}
prices := make([]float64, len(points))
for i, p := range points {
prices[i] = p.Price
}
return &PriceStats{
QuoteCount: len(points),
MinPrice: CalculatePercentile(prices, 0),
MaxPrice: CalculatePercentile(prices, 100),
MedianPrice: CalculateMedian(prices),
AveragePrice: CalculateAverage(prices),
StdDeviation: CalculateStdDev(prices),
LatestPrice: points[0].Price,
LatestDate: points[0].Date,
OldestDate: points[len(points)-1].Date,
Percentile25: CalculatePercentile(prices, 25),
Percentile75: CalculatePercentile(prices, 75),
}, nil
}
type PriceStats struct {
QuoteCount int `json:"quote_count"`
MinPrice float64 `json:"min_price"`
MaxPrice float64 `json:"max_price"`
MedianPrice float64 `json:"median_price"`
AveragePrice float64 `json:"average_price"`
StdDeviation float64 `json:"std_deviation"`
LatestPrice float64 `json:"latest_price"`
LatestDate time.Time `json:"latest_date"`
OldestDate time.Time `json:"oldest_date"`
Percentile25 float64 `json:"percentile_25"`
Percentile75 float64 `json:"percentile_75"`
}
// RecalculateAllPrices recalculates prices for all components
func (s *Service) RecalculateAllPrices() (updated int, errors int) {
// Get all components
filter := repository.ComponentFilter{}
offset := 0
limit := 100
for {
components, _, err := s.componentRepo.List(filter, offset, limit)
if err != nil || len(components) == 0 {
break
}
for _, comp := range components {
if err := s.UpdateComponentPrice(comp.LotName); err != nil {
errors++
} else {
updated++
}
}
offset += limit
}
return updated, errors
}

View File

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

View File

@@ -2,10 +2,13 @@ package services
import ( import (
"errors" "errors"
"fmt"
"sync"
"time"
"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"
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
) )
var ( var (
@@ -17,21 +20,41 @@ var (
type QuoteService struct { type QuoteService struct {
componentRepo *repository.ComponentRepository componentRepo *repository.ComponentRepository
statsRepo *repository.StatsRepository statsRepo *repository.StatsRepository
pricingService *pricing.Service pricelistRepo *repository.PricelistRepository
localDB *localdb.LocalDB
pricingService priceResolver
cacheMu sync.RWMutex
priceCache map[string]cachedLotPrice
cacheTTL time.Duration
}
type priceResolver interface {
GetEffectivePrice(lotName string) (*float64, error)
} }
func NewQuoteService( func NewQuoteService(
componentRepo *repository.ComponentRepository, componentRepo *repository.ComponentRepository,
statsRepo *repository.StatsRepository, statsRepo *repository.StatsRepository,
pricingService *pricing.Service, pricelistRepo *repository.PricelistRepository,
localDB *localdb.LocalDB,
pricingService priceResolver,
) *QuoteService { ) *QuoteService {
return &QuoteService{ return &QuoteService{
componentRepo: componentRepo, componentRepo: componentRepo,
statsRepo: statsRepo, statsRepo: statsRepo,
pricelistRepo: pricelistRepo,
localDB: localDB,
pricingService: pricingService, pricingService: pricingService,
priceCache: make(map[string]cachedLotPrice, 4096),
cacheTTL: 10 * time.Second,
} }
} }
type cachedLotPrice struct {
price *float64
expiresAt time.Time
}
type QuoteItem struct { type QuoteItem struct {
LotName string `json:"lot_name"` LotName string `json:"lot_name"`
Quantity int `json:"quantity"` Quantity int `json:"quantity"`
@@ -57,12 +80,86 @@ type QuoteRequest struct {
} `json:"items"` } `json:"items"`
} }
type PriceLevelsRequest struct {
Items []struct {
LotName string `json:"lot_name"`
Quantity int `json:"quantity"`
} `json:"items"`
PricelistIDs map[string]uint `json:"pricelist_ids,omitempty"`
NoCache bool `json:"no_cache,omitempty"`
}
type PriceLevelsItem struct {
LotName string `json:"lot_name"`
Quantity int `json:"quantity"`
EstimatePrice *float64 `json:"estimate_price"`
WarehousePrice *float64 `json:"warehouse_price"`
CompetitorPrice *float64 `json:"competitor_price"`
DeltaWhEstimateAbs *float64 `json:"delta_wh_estimate_abs"`
DeltaWhEstimatePct *float64 `json:"delta_wh_estimate_pct"`
DeltaCompEstimateAbs *float64 `json:"delta_comp_estimate_abs"`
DeltaCompEstimatePct *float64 `json:"delta_comp_estimate_pct"`
DeltaCompWhAbs *float64 `json:"delta_comp_wh_abs"`
DeltaCompWhPct *float64 `json:"delta_comp_wh_pct"`
PriceMissing []string `json:"price_missing"`
}
type PriceLevelsResult struct {
Items []PriceLevelsItem `json:"items"`
ResolvedPricelistIDs map[string]uint `json:"resolved_pricelist_ids"`
}
func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidationResult, error) { func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidationResult, error) {
if len(req.Items) == 0 { if len(req.Items) == 0 {
return nil, ErrEmptyQuote return nil, ErrEmptyQuote
} }
// Strict local-first path: calculations use local SQLite snapshot regardless of online status.
if s.localDB != nil {
result := &QuoteValidationResult{
Valid: true,
Items: make([]QuoteItem, 0, len(req.Items)),
Errors: make([]string, 0),
Warnings: make([]string, 0),
}
var total float64
for _, reqItem := range req.Items {
localComp, err := s.localDB.GetLocalComponent(reqItem.LotName)
if err != nil {
result.Valid = false
result.Errors = append(result.Errors, "Component not found: "+reqItem.LotName)
continue
}
item := QuoteItem{
LotName: reqItem.LotName,
Quantity: reqItem.Quantity,
Description: localComp.LotDescription,
Category: localComp.Category,
HasPrice: false,
UnitPrice: 0,
TotalPrice: 0,
}
if localComp.CurrentPrice != nil && *localComp.CurrentPrice > 0 {
item.UnitPrice = *localComp.CurrentPrice
item.TotalPrice = *localComp.CurrentPrice * float64(reqItem.Quantity)
item.HasPrice = true
total += item.TotalPrice
} else {
result.Warnings = append(result.Warnings, "No price available for: "+reqItem.LotName)
}
result.Items = append(result.Items, item)
}
result.Total = total
return result, nil
}
if s.componentRepo == nil || s.pricingService == nil { if s.componentRepo == nil || s.pricingService == nil {
return nil, errors.New("offline mode: quote calculation not available") return nil, errors.New("quote calculation not available")
} }
result := &QuoteValidationResult{ result := &QuoteValidationResult{
@@ -130,6 +227,258 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
return result, nil return result, nil
} }
func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLevelsResult, error) {
if len(req.Items) == 0 {
return nil, ErrEmptyQuote
}
lotNames := make([]string, 0, len(req.Items))
seenLots := make(map[string]struct{}, len(req.Items))
for _, reqItem := range req.Items {
if _, ok := seenLots[reqItem.LotName]; ok {
continue
}
seenLots[reqItem.LotName] = struct{}{}
lotNames = append(lotNames, reqItem.LotName)
}
result := &PriceLevelsResult{
Items: make([]PriceLevelsItem, 0, len(req.Items)),
ResolvedPricelistIDs: map[string]uint{},
}
type levelState struct {
id uint
prices map[string]float64
}
levelBySource := map[models.PricelistSource]*levelState{
models.PricelistSourceEstimate: {prices: map[string]float64{}},
models.PricelistSourceWarehouse: {prices: map[string]float64{}},
models.PricelistSourceCompetitor: {prices: map[string]float64{}},
}
for source, st := range levelBySource {
sourceKey := string(source)
if req.PricelistIDs != nil {
if explicitID, ok := req.PricelistIDs[sourceKey]; ok && explicitID > 0 {
st.id = explicitID
result.ResolvedPricelistIDs[sourceKey] = explicitID
}
}
if st.id == 0 && s.pricelistRepo != nil {
latest, err := s.pricelistRepo.GetLatestActiveBySource(sourceKey)
if err == nil {
st.id = latest.ID
result.ResolvedPricelistIDs[sourceKey] = latest.ID
}
}
if st.id == 0 {
continue
}
prices, err := s.lookupPricesByPricelistID(st.id, lotNames, req.NoCache)
if err == nil {
st.prices = prices
}
}
for _, reqItem := range req.Items {
item := PriceLevelsItem{
LotName: reqItem.LotName,
Quantity: reqItem.Quantity,
PriceMissing: make([]string, 0, 3),
}
if p, ok := levelBySource[models.PricelistSourceEstimate].prices[reqItem.LotName]; ok && p > 0 {
price := p
item.EstimatePrice = &price
}
if p, ok := levelBySource[models.PricelistSourceWarehouse].prices[reqItem.LotName]; ok && p > 0 {
price := p
item.WarehousePrice = &price
}
if p, ok := levelBySource[models.PricelistSourceCompetitor].prices[reqItem.LotName]; ok && p > 0 {
price := p
item.CompetitorPrice = &price
}
if item.EstimatePrice == nil {
item.PriceMissing = append(item.PriceMissing, string(models.PricelistSourceEstimate))
}
if item.WarehousePrice == nil {
item.PriceMissing = append(item.PriceMissing, string(models.PricelistSourceWarehouse))
}
if item.CompetitorPrice == nil {
item.PriceMissing = append(item.PriceMissing, string(models.PricelistSourceCompetitor))
}
item.DeltaWhEstimateAbs, item.DeltaWhEstimatePct = calculateDelta(item.WarehousePrice, item.EstimatePrice)
item.DeltaCompEstimateAbs, item.DeltaCompEstimatePct = calculateDelta(item.CompetitorPrice, item.EstimatePrice)
item.DeltaCompWhAbs, item.DeltaCompWhPct = calculateDelta(item.CompetitorPrice, item.WarehousePrice)
result.Items = append(result.Items, item)
}
return result, nil
}
func (s *QuoteService) lookupPricesByPricelistID(pricelistID uint, lotNames []string, noCache bool) (map[string]float64, error) {
result := make(map[string]float64, len(lotNames))
if pricelistID == 0 || len(lotNames) == 0 {
return result, nil
}
missing := make([]string, 0, len(lotNames))
if noCache {
missing = append(missing, lotNames...)
} else {
now := time.Now()
s.cacheMu.RLock()
for _, lotName := range lotNames {
if entry, ok := s.priceCache[s.cacheKey(pricelistID, lotName)]; ok && entry.expiresAt.After(now) {
if entry.price != nil && *entry.price > 0 {
result[lotName] = *entry.price
}
continue
}
missing = append(missing, lotName)
}
s.cacheMu.RUnlock()
}
if len(missing) == 0 {
return result, nil
}
loaded := make(map[string]float64, len(missing))
if s.pricelistRepo != nil {
prices, err := s.pricelistRepo.GetPricesForLots(pricelistID, missing)
if err == nil {
for lotName, price := range prices {
if price > 0 {
result[lotName] = price
loaded[lotName] = price
}
}
s.updateCache(pricelistID, missing, loaded)
return result, nil
}
}
// Fallback path (usually offline): local per-lot lookup.
if s.localDB != nil {
for _, lotName := range missing {
price, found := s.lookupPriceByPricelistID(pricelistID, lotName)
if found && price > 0 {
result[lotName] = price
loaded[lotName] = price
}
}
s.updateCache(pricelistID, missing, loaded)
return result, nil
}
return result, fmt.Errorf("price lookup unavailable for pricelist %d", pricelistID)
}
func (s *QuoteService) updateCache(pricelistID uint, requested []string, loaded map[string]float64) {
if len(requested) == 0 {
return
}
expiresAt := time.Now().Add(s.cacheTTL)
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
for _, lotName := range requested {
if price, ok := loaded[lotName]; ok && price > 0 {
priceCopy := price
s.priceCache[s.cacheKey(pricelistID, lotName)] = cachedLotPrice{
price: &priceCopy,
expiresAt: expiresAt,
}
continue
}
s.priceCache[s.cacheKey(pricelistID, lotName)] = cachedLotPrice{
price: nil,
expiresAt: expiresAt,
}
}
}
func (s *QuoteService) cacheKey(pricelistID uint, lotName string) string {
return fmt.Sprintf("%d|%s", pricelistID, lotName)
}
func calculateDelta(target, base *float64) (*float64, *float64) {
if target == nil || base == nil {
return nil, nil
}
abs := *target - *base
if *base == 0 {
return &abs, nil
}
pct := (abs / *base) * 100
return &abs, &pct
}
func (s *QuoteService) lookupLevelPrice(source models.PricelistSource, lotName string, pricelistIDs map[string]uint) (*float64, uint) {
sourceKey := string(source)
if id, ok := pricelistIDs[sourceKey]; ok && id > 0 {
price, found := s.lookupPriceByPricelistID(id, lotName)
if found {
return &price, id
}
return nil, id
}
if s.pricelistRepo != nil {
price, id, err := s.pricelistRepo.GetPriceForLotBySource(sourceKey, lotName)
if err == nil && price > 0 {
return &price, id
}
latest, latestErr := s.pricelistRepo.GetLatestActiveBySource(sourceKey)
if latestErr == nil {
return nil, latest.ID
}
}
if s.localDB != nil {
localPL, err := s.localDB.GetLatestLocalPricelistBySource(sourceKey)
if err != nil {
return nil, 0
}
price, err := s.localDB.GetLocalPriceForLot(localPL.ID, lotName)
if err != nil || price <= 0 {
return nil, localPL.ServerID
}
return &price, localPL.ServerID
}
return nil, 0
}
func (s *QuoteService) lookupPriceByPricelistID(pricelistID uint, lotName string) (float64, bool) {
if s.pricelistRepo != nil {
price, err := s.pricelistRepo.GetPriceForLot(pricelistID, lotName)
if err == nil && price > 0 {
return price, true
}
}
if s.localDB != nil {
localPL, err := s.localDB.GetLocalPricelistByServerID(pricelistID)
if err != nil {
return 0, false
}
price, err := s.localDB.GetLocalPriceForLot(localPL.ID, lotName)
if err == nil && price > 0 {
return price, true
}
}
return 0, false
}
// RecordUsage records that components were used in a quote // 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 { if s.statsRepo == nil {

View File

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

View File

@@ -5,12 +5,16 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"sort"
"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" "gorm.io/gorm"
) )
@@ -18,8 +22,9 @@ 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
@@ -30,6 +35,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"`
@@ -38,6 +51,13 @@ 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. // ConfigImportResult represents server->local configuration import stats.
type ConfigImportResult struct { type ConfigImportResult struct {
Imported int `json:"imported"` Imported int `json:"imported"`
@@ -45,12 +65,21 @@ type ConfigImportResult struct {
Skipped int `json:"skipped"` Skipped int `json:"skipped"`
} }
// ProjectImportResult represents server->local project import stats.
type ProjectImportResult struct {
Imported int `json:"imported"`
Updated int `json:"updated"`
Skipped int `json:"skipped"`
}
// ConfigurationChangePayload is stored in pending_changes.payload for configuration events. // 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. // It carries version metadata so sync can push the latest snapshot and prepare for conflict resolution.
type ConfigurationChangePayload struct { type ConfigurationChangePayload struct {
EventID string `json:"event_id"` EventID string `json:"event_id"`
IdempotencyKey string `json:"idempotency_key"` IdempotencyKey string `json:"idempotency_key"`
ConfigurationUUID string `json:"configuration_uuid"` 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 Operation string `json:"operation"` // create/update/rollback/deactivate/reactivate/delete
CurrentVersionID string `json:"current_version_id,omitempty"` CurrentVersionID string `json:"current_version_id,omitempty"`
CurrentVersionNo int `json:"current_version_no,omitempty"` CurrentVersionNo int `json:"current_version_no,omitempty"`
@@ -60,10 +89,19 @@ type ConfigurationChangePayload struct {
CreatedBy *string `json:"created_by,omitempty"` 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. // ImportConfigurationsToLocal imports configurations from MariaDB into local SQLite.
// Existing local configs with pending local changes are skipped to avoid data loss. // Existing local configs with pending local changes are skipped to avoid data loss.
func (s *Service) ImportConfigurationsToLocal() (*ConfigImportResult, error) { func (s *Service) ImportConfigurationsToLocal() (*ConfigImportResult, error) {
mariaDB, err := s.connMgr.GetDB() mariaDB, err := s.getDB()
if err != nil { if err != nil {
return nil, ErrOffline return nil, ErrOffline
} }
@@ -123,15 +161,87 @@ func (s *Service) ImportConfigurationsToLocal() (*ConfigImportResult, error) {
return result, nil return result, nil
} }
// ImportProjectsToLocal imports projects from MariaDB into local SQLite.
// Existing local projects with pending local changes are skipped to avoid data loss.
func (s *Service) ImportProjectsToLocal() (*ProjectImportResult, error) {
mariaDB, err := s.getDB()
if err != nil {
return nil, ErrOffline
}
projectRepo := repository.NewProjectRepository(mariaDB)
result := &ProjectImportResult{}
offset := 0
const limit = 200
for {
serverProjects, _, err := projectRepo.List(offset, limit, true)
if err != nil {
return nil, fmt.Errorf("listing server projects: %w", err)
}
if len(serverProjects) == 0 {
break
}
now := time.Now()
for i := range serverProjects {
project := serverProjects[i]
existing, getErr := s.localDB.GetProjectByUUID(project.UUID)
if getErr != nil && !errors.Is(getErr, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("getting local project %s: %w", project.UUID, getErr)
}
if existing != nil && getErr == nil {
// Keep unsynced local changes intact.
if existing.SyncStatus == "pending" {
result.Skipped++
continue
}
existing.OwnerUsername = project.OwnerUsername
existing.Name = project.Name
existing.TrackerURL = project.TrackerURL
existing.IsActive = project.IsActive
existing.IsSystem = project.IsSystem
existing.CreatedAt = project.CreatedAt
existing.UpdatedAt = project.UpdatedAt
serverID := project.ID
existing.ServerID = &serverID
existing.SyncStatus = "synced"
existing.SyncedAt = &now
if err := s.localDB.SaveProject(existing); err != nil {
return nil, fmt.Errorf("saving local project %s: %w", project.UUID, err)
}
result.Updated++
continue
}
localProject := localdb.ProjectToLocal(&project)
localProject.SyncStatus = "synced"
localProject.SyncedAt = &now
if err := s.localDB.SaveProject(localProject); err != nil {
return nil, fmt.Errorf("saving local project %s: %w", project.UUID, err)
}
result.Imported++
}
offset += len(serverProjects)
}
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)
activeCount, err := pricelistRepo.CountActive() activeCount, err := pricelistRepo.CountActive()
if err == nil { if err == nil {
@@ -169,34 +279,41 @@ 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
} }
pricelistRepo := repository.NewPricelistRepository(mariaDB) pricelistRepo := repository.NewPricelistRepository(mariaDB)
latestServer, err := pricelistRepo.GetLatestActive() sources := []models.PricelistSource{
if err != nil { models.PricelistSourceEstimate,
// If no pricelists on server, no need to sync models.PricelistSourceWarehouse,
return false, nil models.PricelistSourceCompetitor,
} }
for _, source := range sources {
latestServer, err := pricelistRepo.GetLatestActiveBySource(string(source))
if err != nil {
// No active pricelist for this source yet.
continue
}
latestLocal, err := s.localDB.GetLatestLocalPricelist() latestLocal, err := s.localDB.GetLatestLocalPricelistBySource(string(source))
if err != nil { if err != nil {
// No local pricelists, need to sync // No local pricelist for an existing source on server.
return true, nil return true, nil
} }
// If server has newer pricelist, need sync // If server has newer pricelist for this source, need sync.
if latestServer.ID != latestLocal.ServerID { if latestServer.ID != latestLocal.ServerID {
return true, nil return true, nil
}
} }
return false, nil return false, nil
@@ -205,9 +322,12 @@ func (s *Service) NeedSync() (bool, error) {
// SyncPricelists synchronizes all active pricelists from server to local SQLite // SyncPricelists synchronizes all active pricelists from server to local SQLite
func (s *Service) SyncPricelists() (int, error) { func (s *Service) SyncPricelists() (int, error) {
slog.Info("starting pricelist sync") slog.Info("starting pricelist sync")
if _, err := s.EnsureReadinessForSync(); err != nil {
return 0, err
}
// 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)
} }
@@ -222,16 +342,16 @@ func (s *Service) SyncPricelists() (int, error) {
} }
synced := 0 synced := 0
var latestLocalID uint var latestEstimateLocalID uint
var latestServerID uint var latestEstimateCreatedAt time.Time
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 by server ID // Track latest estimate pricelist by created_at for component refresh.
if pl.ID > latestServerID { if pl.Source == string(models.PricelistSourceEstimate) && (latestEstimateCreatedAt.IsZero() || pl.CreatedAt.After(latestEstimateCreatedAt)) {
latestServerID = pl.ID latestEstimateCreatedAt = pl.CreatedAt
latestLocalID = existing.ID latestEstimateLocalID = existing.ID
} }
continue continue
} }
@@ -239,6 +359,7 @@ func (s *Service) SyncPricelists() (int, error) {
// Create local pricelist // Create local pricelist
localPL := &localdb.LocalPricelist{ localPL := &localdb.LocalPricelist{
ServerID: pl.ID, ServerID: pl.ID,
Source: pl.Source,
Version: pl.Version, Version: pl.Version,
Name: pl.Notification, // Using notification as name Name: pl.Notification, // Using notification as name
CreatedAt: pl.CreatedAt, CreatedAt: pl.CreatedAt,
@@ -260,16 +381,16 @@ 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)
} }
if pl.ID > latestServerID { if pl.Source == string(models.PricelistSourceEstimate) && (latestEstimateCreatedAt.IsZero() || pl.CreatedAt.After(latestEstimateCreatedAt)) {
latestServerID = pl.ID latestEstimateCreatedAt = pl.CreatedAt
latestLocalID = localPL.ID latestEstimateLocalID = localPL.ID
} }
synced++ synced++
} }
// Update component prices from latest pricelist // Update component prices from latest estimate pricelist only.
if latestLocalID > 0 { if latestEstimateLocalID > 0 {
updated, err := s.localDB.UpdateComponentPricesFromPricelist(latestLocalID) updated, err := s.localDB.UpdateComponentPricesFromPricelist(latestEstimateLocalID)
if err != nil { if err != nil {
slog.Warn("failed to update component prices from pricelist", "error", err) slog.Warn("failed to update component prices from pricelist", "error", err)
} else { } else {
@@ -279,11 +400,168 @@ 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)
}
activeUsers, err := s.listConnectedDBUsers(mariaDB)
if err != nil {
slog.Debug("sync status: failed to load connected DB users", "error", err)
activeUsers = map[string]struct{}{}
}
now := time.Now().UTC()
result := make([]UserSyncStatus, 0, len(rows)+len(activeUsers))
for i := range rows {
r := rows[i]
username := strings.TrimSpace(r.Username)
if username == "" {
continue
}
isOnline := now.Sub(r.LastSyncAt) <= onlineThreshold
if _, connected := activeUsers[username]; connected {
isOnline = true
delete(activeUsers, username)
}
appVersion := strings.TrimSpace(r.AppVersion)
result = append(result, UserSyncStatus{
Username: username,
LastSyncAt: r.LastSyncAt,
AppVersion: appVersion,
IsOnline: isOnline,
})
}
for username := range activeUsers {
result = append(result, UserSyncStatus{
Username: username,
LastSyncAt: now,
AppVersion: "",
IsOnline: true,
})
}
sort.SliceStable(result, func(i, j int) bool {
if result[i].IsOnline != result[j].IsOnline {
return result[i].IsOnline
}
if result[i].LastSyncAt.Equal(result[j].LastSyncAt) {
return strings.ToLower(result[i].Username) < strings.ToLower(result[j].Username)
}
return result[i].LastSyncAt.After(result[j].LastSyncAt)
})
return result, nil
}
func (s *Service) listConnectedDBUsers(mariaDB *gorm.DB) (map[string]struct{}, error) {
type processUserRow struct {
Username string `gorm:"column:username"`
}
var rows []processUserRow
if err := mariaDB.Raw(`
SELECT DISTINCT TRIM(USER) AS username
FROM information_schema.PROCESSLIST
WHERE COALESCE(TRIM(USER), '') <> ''
AND DB = DATABASE()
`).Scan(&rows).Error; err != nil {
return nil, err
}
users := make(map[string]struct{}, len(rows))
for i := range rows {
username := strings.TrimSpace(rows[i].Username)
if username == "" {
continue
}
users[username] = struct{}{}
}
return users, 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
@@ -300,7 +578,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)
} }
@@ -317,10 +595,14 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
// Convert and save locally // Convert and save locally
localItems := make([]localdb.LocalPricelistItem, len(serverItems)) localItems := make([]localdb.LocalPricelistItem, len(serverItems))
for i, item := range serverItems { for i, item := range serverItems {
partnumbers := make(localdb.LocalStringList, 0, len(item.Partnumbers))
partnumbers = append(partnumbers, item.Partnumbers...)
localItems[i] = localdb.LocalPricelistItem{ localItems[i] = localdb.LocalPricelistItem{
PricelistID: localPricelistID, PricelistID: localPricelistID,
LotName: item.LotName, LotName: item.LotName,
Price: item.Price, Price: item.Price,
AvailableQty: item.AvailableQty,
Partnumbers: partnumbers,
} }
} }
@@ -397,6 +679,17 @@ 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) {
if _, err := s.EnsureReadinessForSync(); err != nil {
return 0, err
}
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)
@@ -410,8 +703,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)
@@ -438,6 +732,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:
@@ -445,6 +741,83 @@ 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
if err := projectRepo.UpsertByUUID(&project); err != nil {
return fmt.Errorf("upsert project on server: %w", err)
}
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 {
@@ -477,7 +850,7 @@ func (s *Service) pushConfigurationCreate(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)
} }
@@ -487,6 +860,12 @@ func (s *Service) pushConfigurationCreate(change *localdb.PendingChange) error {
if err := s.ensureConfigurationOwner(mariaDB, &cfg); err != nil { if err := s.ensureConfigurationOwner(mariaDB, &cfg); err != nil {
return fmt.Errorf("resolve configuration owner: %w", err) 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 {
@@ -532,7 +911,7 @@ func (s *Service) pushConfigurationUpdate(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)
} }
@@ -542,6 +921,12 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
if err := s.ensureConfigurationOwner(mariaDB, &cfg); err != nil { if err := s.ensureConfigurationOwner(mariaDB, &cfg); err != nil {
return fmt.Errorf("resolve configuration owner: %w", err) 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
@@ -552,15 +937,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 {
@@ -605,15 +1009,96 @@ func (s *Service) ensureConfigurationOwner(mariaDB *gorm.DB, cfg *models.Configu
return fmt.Errorf("owner username is empty") return fmt.Errorf("owner username is empty")
} }
userID, err := models.EnsureDBUser(mariaDB, ownerUsername) // user_id is legacy and no longer used for ownership in local-first mode.
if err != nil { // Keep it NULL on writes; ownership is represented by owner_username.
return err cfg.UserID = nil
} cfg.AppVersion = appmeta.Version()
if userID == 0 { return nil
return fmt.Errorf("resolved user ID is 0 for owner %q", ownerUsername) }
func (s *Service) ensureConfigurationProject(mariaDB *gorm.DB, cfg *models.Configuration) error {
if cfg == nil {
return fmt.Errorf("configuration is nil")
} }
cfg.UserID = userID 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.UpsertByUUID(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 return nil
} }
@@ -641,6 +1126,10 @@ func (s *Service) resolveConfigurationPayloadForPush(change *localdb.PendingChan
currentCfg, currentVersionID, currentVersionNo, err := s.loadCurrentConfigurationState(payload.ConfigurationUUID) currentCfg, currentVersionID, currentVersionNo, err := s.loadCurrentConfigurationState(payload.ConfigurationUUID)
if err != nil { 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. // create->deactivate race: config may no longer be active/visible locally, skip stale create.
if change.Operation == "create" { if change.Operation == "create" {
return payload, payload.Snapshot, true, nil return payload, payload.Snapshot, true, nil
@@ -660,6 +1149,7 @@ func (s *Service) resolveConfigurationPayloadForPush(change *localdb.PendingChan
if currentVersionNo > 0 { if currentVersionNo > 0 {
payload.CurrentVersionNo = currentVersionNo payload.CurrentVersionNo = currentVersionNo
} }
payload.PricelistID = currentCfg.PricelistID
} }
isStale := false isStale := false
@@ -696,6 +1186,8 @@ func decodeConfigurationChangePayload(change *localdb.PendingChange) (Configurat
EventID: "", EventID: "",
IdempotencyKey: fmt.Sprintf("%s:%s:legacy", cfg.UUID, change.Operation), IdempotencyKey: fmt.Sprintf("%s:%s:legacy", cfg.UUID, change.Operation),
ConfigurationUUID: cfg.UUID, ConfigurationUUID: cfg.UUID,
ProjectUUID: cfg.ProjectUUID,
PricelistID: cfg.PricelistID,
Operation: change.Operation, Operation: change.Operation,
ConflictPolicy: "last_write_wins", ConflictPolicy: "last_write_wins",
Snapshot: cfg, Snapshot: cfg,
@@ -752,7 +1244,7 @@ func (s *Service) loadCurrentConfigurationState(configurationUUID string) (model
// 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)
} }
@@ -776,3 +1268,23 @@ func (s *Service) pushConfigurationDelete(change *localdb.PendingChange) error {
slog.Info("configuration deleted on server", "uuid", change.EntityUUID) slog.Info("configuration deleted on server", "uuid", change.EntityUUID)
return nil return nil
} }
func (s *Service) getDB() (*gorm.DB, error) {
if s.directDB != nil {
return s.directDB, nil
}
if s.connMgr == nil {
return nil, ErrOffline
}
return s.connMgr.GetDB()
}
func (s *Service) getConnectionStatus() db.ConnectionStatus {
if s.directDB != nil {
return db.ConnectionStatus{IsConnected: true}
}
if s.connMgr == nil {
return db.ConnectionStatus{IsConnected: false}
}
return s.connMgr.GetStatus()
}

View File

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

View File

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

View File

@@ -71,6 +71,15 @@ func (w *Worker) runSync() {
return return
} }
if readiness, err := w.service.EnsureReadinessForSync(); err != nil {
w.logger.Warn("background sync: blocked by readiness guard",
"error", err,
"reason_code", readiness.ReasonCode,
"reason_text", readiness.ReasonText,
)
return
}
// Push pending changes first // Push pending changes first
pushed, err := w.service.PushPendingChanges() pushed, err := w.service.PushPendingChanges()
if err != nil { if err != nil {
@@ -83,7 +92,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")
} }

View File

@@ -14,6 +14,7 @@ CREATE TABLE local_configuration_versions (
data TEXT NOT NULL, data TEXT NOT NULL,
change_note TEXT NULL, change_note TEXT NULL,
created_by TEXT NULL, created_by TEXT NULL,
app_version TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (configuration_uuid) REFERENCES local_configurations(uuid), FOREIGN KEY (configuration_uuid) REFERENCES local_configurations(uuid),
UNIQUE(configuration_uuid, version_no) UNIQUE(configuration_uuid, version_no)
@@ -36,6 +37,7 @@ INSERT INTO local_configuration_versions (
data, data,
change_note, change_note,
created_by, created_by,
app_version,
created_at created_at
) )
SELECT SELECT
@@ -58,10 +60,12 @@ SELECT
'synced_at', synced_at, 'synced_at', synced_at,
'sync_status', sync_status, 'sync_status', sync_status,
'original_user_id', original_user_id, 'original_user_id', original_user_id,
'original_username', original_username 'original_username', original_username,
'app_version', NULL
) AS data, ) AS data,
'Initial snapshot backfill (v1)' AS change_note, 'Initial snapshot backfill (v1)' AS change_note,
NULL AS created_by, NULL AS created_by,
NULL AS app_version,
COALESCE(created_at, CURRENT_TIMESTAMP) AS created_at COALESCE(created_at, CURRENT_TIMESTAMP) AS created_at
FROM local_configurations; FROM local_configurations;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS stock_log (
stock_log_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
lot VARCHAR(255) NOT NULL,
supplier VARCHAR(255) NULL,
date DATE NOT NULL,
price DECIMAL(12,2) NOT NULL,
quality VARCHAR(255) NULL,
comments TEXT NULL,
vendor VARCHAR(255) NULL,
qty DECIMAL(14,3) NULL,
INDEX idx_stock_log_lot_date (lot, date),
INDEX idx_stock_log_date (date),
INDEX idx_stock_log_vendor (vendor)
);

View File

@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS lot_partnumbers (
partnumber VARCHAR(255) NOT NULL,
lot_name VARCHAR(255) NOT NULL DEFAULT '',
description VARCHAR(10000) NULL,
PRIMARY KEY (partnumber, lot_name),
INDEX idx_lot_partnumbers_lot_name (lot_name)
);

View File

@@ -0,0 +1,25 @@
-- Add per-source pricelist bindings for configurations
ALTER TABLE qt_configurations
ADD COLUMN IF NOT EXISTS warehouse_pricelist_id BIGINT UNSIGNED NULL AFTER pricelist_id,
ADD COLUMN IF NOT EXISTS competitor_pricelist_id BIGINT UNSIGNED NULL AFTER warehouse_pricelist_id,
ADD COLUMN IF NOT EXISTS disable_price_refresh BOOLEAN NOT NULL DEFAULT FALSE AFTER competitor_pricelist_id;
ALTER TABLE qt_configurations
ADD INDEX IF NOT EXISTS idx_qt_configurations_warehouse_pricelist_id (warehouse_pricelist_id),
ADD INDEX IF NOT EXISTS idx_qt_configurations_competitor_pricelist_id (competitor_pricelist_id);
-- Optional FK bindings (safe if re-run due IF NOT EXISTS on columns/indexes)
-- If your MariaDB version does not support IF NOT EXISTS for FK names, duplicate-FK errors are ignored by migration runner.
ALTER TABLE qt_configurations
ADD CONSTRAINT fk_qt_configurations_warehouse_pricelist_id
FOREIGN KEY (warehouse_pricelist_id)
REFERENCES qt_pricelists(id)
ON DELETE RESTRICT
ON UPDATE CASCADE;
ALTER TABLE qt_configurations
ADD CONSTRAINT fk_qt_configurations_competitor_pricelist_id
FOREIGN KEY (competitor_pricelist_id)
REFERENCES qt_pricelists(id)
ON DELETE RESTRICT
ON UPDATE CASCADE;

View File

@@ -0,0 +1,25 @@
-- Allow placeholder mappings (partnumber without bound lot) and store import description.
ALTER TABLE lot_partnumbers
ADD COLUMN IF NOT EXISTS description VARCHAR(10000) NULL AFTER lot_name;
ALTER TABLE lot_partnumbers
MODIFY COLUMN lot_name VARCHAR(255) NOT NULL DEFAULT '';
-- Drop FK on lot_name if it exists to allow unresolved placeholders.
SET @lp_fk_name := (
SELECT kcu.CONSTRAINT_NAME
FROM information_schema.KEY_COLUMN_USAGE kcu
WHERE kcu.TABLE_SCHEMA = DATABASE()
AND kcu.TABLE_NAME = 'lot_partnumbers'
AND kcu.COLUMN_NAME = 'lot_name'
AND kcu.REFERENCED_TABLE_NAME IS NOT NULL
LIMIT 1
);
SET @lp_drop_fk_sql := IF(
@lp_fk_name IS NULL,
'SELECT 1',
CONCAT('ALTER TABLE lot_partnumbers DROP FOREIGN KEY `', @lp_fk_name, '`')
);
PREPARE lp_stmt FROM @lp_drop_fk_sql;
EXECUTE lp_stmt;
DEALLOCATE PREPARE lp_stmt;

View File

@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS stock_ignore_rules (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
target VARCHAR(20) NOT NULL,
match_type VARCHAR(20) NOT NULL,
pattern VARCHAR(500) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_stock_ignore_rule (target, match_type, pattern),
KEY idx_stock_ignore_target (target)
);

View File

@@ -0,0 +1,2 @@
ALTER TABLE stock_log
CHANGE COLUMN lot partnumber VARCHAR(255) NOT NULL;

View File

@@ -0,0 +1,3 @@
-- Add only_in_stock toggle to configuration settings persisted in MariaDB.
ALTER TABLE qt_configurations
ADD COLUMN IF NOT EXISTS only_in_stock BOOLEAN NOT NULL DEFAULT FALSE AFTER disable_price_refresh;

View File

@@ -0,0 +1,19 @@
-- Ensure fast lookup for /api/quote/price-levels batched queries:
-- SELECT ... FROM qt_pricelist_items WHERE pricelist_id = ? AND lot_name IN (...)
SET @has_idx := (
SELECT COUNT(1)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'qt_pricelist_items'
AND index_name IN ('idx_qt_pricelist_items_pricelist_lot', 'idx_pricelist_lot')
);
SET @ddl := IF(
@has_idx = 0,
'ALTER TABLE qt_pricelist_items ADD INDEX idx_qt_pricelist_items_pricelist_lot (pricelist_id, lot_name)',
'SELECT ''idx_qt_pricelist_items_pricelist_lot already exists, skip'''
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

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

View File

@@ -25,6 +25,25 @@ echo ""
RELEASE_DIR="releases/${VERSION}" RELEASE_DIR="releases/${VERSION}"
mkdir -p "${RELEASE_DIR}" mkdir -p "${RELEASE_DIR}"
# Create release notes template (always include macOS Gatekeeper note)
if [ ! -f "${RELEASE_DIR}/RELEASE_NOTES.md" ]; then
cat > "${RELEASE_DIR}/RELEASE_NOTES.md" <<EOF
# QuoteForge ${VERSION}
Дата релиза: $(date +%Y-%m-%d)
Тег: \`${VERSION}\`
## Что нового
- TODO: опишите ключевые изменения релиза.
## Запуск на macOS
Снимите карантинный атрибут через терминал: \`xattr -d com.apple.quarantine /path/to/qfs-darwin-arm64\`
После этого бинарник запустится без предупреждения Gatekeeper.
EOF
fi
# Build for all platforms # Build for all platforms
echo -e "${YELLOW}→ Building binaries...${NC}" echo -e "${YELLOW}→ Building binaries...${NC}"
make build-all make build-all

File diff suppressed because it is too large Load Diff

View File

@@ -19,8 +19,8 @@
<div class="flex items-center space-x-8"> <div class="flex items-center space-x-8">
<a href="/" class="text-xl font-bold text-blue-600">QuoteForge</a> <a href="/" class="text-xl font-bold text-blue-600">QuoteForge</a>
<div class="hidden md:flex space-x-4"> <div class="hidden md:flex space-x-4">
<a href="/configurator" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Конфигуратор</a> <a href="/projects" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Мои проекты</a>
<a id="admin-pricing-link" href="/admin/pricing" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Администратор цен</a> <a href="/pricelists" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Прайслисты</a>
<a href="/setup" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Настройки</a> <a href="/setup" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Настройки</a>
</div> </div>
</div> </div>
@@ -38,7 +38,7 @@
</div> </div>
</nav> </nav>
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 pb-12"> <main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{{template "content" .}} {{template "content" .}}
</main> </main>
@@ -46,7 +46,7 @@
<!-- Sync Info Modal --> <!-- Sync Info Modal -->
<div id="sync-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden"> <div id="sync-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<div class="bg-white rounded-lg shadow-xl max-w-md w-full mx-4"> <div class="bg-white rounded-lg shadow-xl max-w-lg w-full mx-4">
<div class="p-6"> <div class="p-6">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium text-gray-900">Информация о синхронизации</h3> <h3 class="text-lg font-medium text-gray-900">Информация о синхронизации</h3>
@@ -57,28 +57,72 @@
</button> </button>
</div> </div>
<div class="space-y-4"> <div class="space-y-5">
<!-- Section 1: DB Connection -->
<div> <div>
<h4 class="font-medium text-gray-900">Статус БД</h4> <h4 class="font-medium text-gray-900 mb-2">Подключение к БД</h4>
<p id="modal-db-status" class="text-sm text-gray-600">Проверка...</p> <div class="text-sm space-y-1">
</div> <div class="flex justify-between">
<span class="text-gray-500">Адрес:</span>
<div> <span id="modal-db-host" class="text-gray-700 font-mono"></span>
<h4 class="font-medium text-gray-900">Количество ошибок</h4> </div>
<p id="modal-error-count" class="text-sm text-gray-600">0</p> <div class="flex justify-between">
</div> <span class="text-gray-500">Пользователь:</span>
<span id="modal-db-user" class="text-gray-700"></span>
<div> </div>
<h4 class="font-medium text-gray-900">Последняя синхронизация</h4> <div class="flex justify-between">
<p id="modal-last-sync" class="text-sm text-gray-600">-</p> <span class="text-gray-500">Статус:</span>
</div> <span id="modal-db-status" class="text-gray-700">Проверка...</span>
</div>
<div> <div class="flex justify-between">
<h4 class="font-medium text-gray-900">Список ошибок</h4> <span class="text-gray-500">Последняя синхронизация:</span>
<div id="modal-errors-list" class="mt-2 text-sm text-gray-600 max-h-40 overflow-y-auto"> <span id="modal-last-sync" class="text-gray-700"></span>
<p>Нет ошибок</p> </div>
</div> </div>
</div> </div>
<div id="modal-readiness-section" class="hidden">
<h4 class="font-medium text-red-700 mb-2">Почему синхронизация недоступна</h4>
<div class="bg-red-50 border border-red-200 rounded px-3 py-2 text-sm">
<div id="modal-readiness-reason" class="text-red-700"></div>
<div id="modal-readiness-min-version" class="text-red-600 text-xs mt-1 hidden"></div>
</div>
</div>
<!-- Section 2: Statistics -->
<div>
<h4 class="font-medium text-gray-900 mb-2">Статистика</h4>
<div class="grid grid-cols-2 gap-2 text-sm">
<div class="flex justify-between bg-gray-50 rounded px-3 py-1.5">
<span class="text-gray-500">Компоненты (lot):</span>
<span id="modal-lot-count" class="font-medium text-gray-700"></span>
</div>
<div class="flex justify-between bg-gray-50 rounded px-3 py-1.5">
<span class="text-gray-500">Котировки:</span>
<span id="modal-lotlog-count" class="font-medium text-gray-700"></span>
</div>
<div class="flex justify-between bg-gray-50 rounded px-3 py-1.5">
<span class="text-gray-500">Конфигурации:</span>
<span id="modal-config-count" class="font-medium text-gray-700"></span>
</div>
<div class="flex justify-between bg-gray-50 rounded px-3 py-1.5">
<span class="text-gray-500">Проекты:</span>
<span id="modal-project-count" class="font-medium text-gray-700"></span>
</div>
</div>
</div>
<!-- Section 3: Pending Changes (shown only if any) -->
<div id="modal-pending-section" class="hidden">
<h4 class="font-medium text-gray-900 mb-2">Ожидающие синхронизации</h4>
<div id="modal-pending-list" class="text-sm max-h-40 overflow-y-auto space-y-1"></div>
</div>
<!-- Section 4: Errors (shown only if any) -->
<div id="modal-errors-section" class="hidden">
<h4 class="font-medium text-gray-900 mb-2">Ошибки синхронизации</h4>
<div id="modal-errors-list" class="text-sm max-h-40 overflow-y-auto space-y-1"></div>
</div>
</div> </div>
<div class="mt-6 flex justify-end"> <div class="mt-6 flex justify-end">
@@ -90,13 +134,6 @@
</div> </div>
</div> </div>
<footer class="fixed bottom-0 left-0 right-0 bg-gray-800 text-gray-300 text-xs py-1 px-4">
<div class="max-w-7xl mx-auto flex justify-between">
<span id="db-status">БД: проверка...</span>
<span id="db-counts"></span>
</div>
</footer>
<script> <script>
function showToast(msg, type) { function showToast(msg, type) {
const colors = { success: 'bg-green-500', error: 'bg-red-500', info: 'bg-blue-500' }; const colors = { success: 'bg-green-500', error: 'bg-red-500', info: 'bg-blue-500' };
@@ -129,37 +166,95 @@
const resp = await fetch('/api/sync/info'); const resp = await fetch('/api/sync/info');
const data = await resp.json(); const data = await resp.json();
document.getElementById('modal-db-status').textContent = data.is_online ? 'Подключено' : 'Отключено'; // Section 1: DB Connection
document.getElementById('modal-error-count').textContent = data.error_count; document.getElementById('modal-db-host').textContent = data.db_host ? data.db_host + '/' + data.db_name : '—';
document.getElementById('modal-db-user').textContent = data.db_user || '—';
const statusEl = document.getElementById('modal-db-status');
if (data.is_online) {
statusEl.innerHTML = '<span class="inline-block w-2 h-2 rounded-full bg-green-500 mr-1"></span>Online';
} else {
statusEl.innerHTML = '<span class="inline-block w-2 h-2 rounded-full bg-red-500 mr-1"></span>Offline';
}
if (data.last_sync_at) { if (data.last_sync_at) {
const date = new Date(data.last_sync_at); const date = new Date(data.last_sync_at);
document.getElementById('modal-last-sync').textContent = date.toLocaleString('ru-RU'); document.getElementById('modal-last-sync').textContent = date.toLocaleString('ru-RU');
} else { } else {
document.getElementById('modal-last-sync').textContent = 'Нет данных'; document.getElementById('modal-last-sync').textContent = '';
} }
// Load error list const readinessSection = document.getElementById('modal-readiness-section');
const readinessReason = document.getElementById('modal-readiness-reason');
const readinessMinVersion = document.getElementById('modal-readiness-min-version');
if (data.readiness && data.readiness.blocked) {
readinessSection.classList.remove('hidden');
readinessReason.textContent = data.readiness.reason_text || 'Синхронизация заблокирована preflight-проверкой.';
if (data.readiness.required_min_app_version) {
readinessMinVersion.classList.remove('hidden');
readinessMinVersion.textContent = 'Требуется обновление до версии ' + data.readiness.required_min_app_version;
} else {
readinessMinVersion.classList.add('hidden');
readinessMinVersion.textContent = '';
}
} else {
readinessSection.classList.add('hidden');
readinessReason.textContent = '';
readinessMinVersion.classList.add('hidden');
readinessMinVersion.textContent = '';
}
// Section 2: Statistics
document.getElementById('modal-lot-count').textContent = data.is_online ? data.lot_count.toLocaleString() : '—';
document.getElementById('modal-lotlog-count').textContent = data.is_online ? data.lot_log_count.toLocaleString() : '—';
document.getElementById('modal-config-count').textContent = data.config_count.toLocaleString();
document.getElementById('modal-project-count').textContent = data.project_count.toLocaleString();
// Section 3: Pending changes
const pendingSection = document.getElementById('modal-pending-section');
const pendingList = document.getElementById('modal-pending-list');
if (data.pending_changes && data.pending_changes.length > 0) {
pendingSection.classList.remove('hidden');
pendingList.innerHTML = data.pending_changes.map(ch => {
const shortUUID = ch.entity_uuid.substring(0, 8);
const time = new Date(ch.created_at).toLocaleString('ru-RU');
const hasError = ch.last_error ? ' border-l-2 border-red-400 pl-2' : '';
const errorLine = ch.last_error ? `<div class="text-red-500 text-xs mt-0.5">${ch.last_error}</div>` : '';
return `<div class="bg-gray-50 rounded px-3 py-1.5${hasError}">
<span class="font-medium">${ch.operation}</span>
<span class="text-gray-500">${ch.entity_type}</span>
<span class="font-mono text-xs text-gray-400">${shortUUID}</span>
<span class="text-gray-400 text-xs ml-1">${time}</span>
${errorLine}
</div>`;
}).join('');
} else {
pendingSection.classList.add('hidden');
}
// Section 4: Errors
const errorsSection = document.getElementById('modal-errors-section');
const errorsList = document.getElementById('modal-errors-list'); const errorsList = document.getElementById('modal-errors-list');
if (data.errors && data.errors.length > 0) { if (data.errors && data.errors.length > 0) {
errorsList.innerHTML = data.errors.map(error => errorsSection.classList.remove('hidden');
`<div class="mb-1"><span class="font-medium">${error.timestamp}</span>: ${error.message}</div>` errorsList.innerHTML = data.errors.map(error => {
).join(''); const time = new Date(error.timestamp).toLocaleString('ru-RU');
return `<div class="bg-red-50 text-red-700 rounded px-3 py-1.5">
<span class="text-xs text-red-400">${time}</span>: ${error.message}
</div>`;
}).join('');
} else { } else {
errorsList.innerHTML = '<p>Нет ошибок</p>'; errorsSection.classList.add('hidden');
} }
} catch(e) { } catch(e) {
console.error('Failed to load sync info:', e); console.error('Failed to load sync info:', e);
document.getElementById('modal-db-status').textContent = 'Ошибка загрузки'; document.getElementById('modal-db-status').textContent = 'Ошибка загрузки';
document.getElementById('modal-error-count').textContent = '0';
document.getElementById('modal-last-sync').textContent = '-';
document.getElementById('modal-errors-list').innerHTML = '<p>Ошибка загрузки данных</p>';
} }
} }
// Event delegation for sync dropdown and actions // Event delegation for sync dropdown and actions
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
checkDbStatus(); loadDBUser();
checkWritePermission(); checkWritePermission();
}); });
@@ -190,6 +285,11 @@
showToast(successMessage, 'success'); showToast(successMessage, 'success');
// Update last sync time - removed since dropdown is gone // Update last sync time - removed since dropdown is gone
// loadLastSyncTime(); // loadLastSyncTime();
} else if (resp.status === 423) {
const reason = data.reason_text || data.error || 'Синхронизация заблокирована.';
showToast(reason, 'error');
openSyncModal();
loadSyncInfo();
} else { } else {
showToast('Ошибка: ' + (data.error || 'неизвестная ошибка'), 'error'); showToast('Ошибка: ' + (data.error || 'неизвестная ошибка'), 'error');
} }
@@ -214,26 +314,16 @@
syncAction('/api/sync/all', 'Полная синхронизация завершена', button, originalHTML); syncAction('/api/sync/all', 'Полная синхронизация завершена', button, originalHTML);
} }
async function checkDbStatus() { async function loadDBUser() {
try { try {
const resp = await fetch('/api/db-status'); const resp = await fetch('/api/db-status');
const data = await resp.json(); const data = await resp.json();
const statusEl = document.getElementById('db-status');
const countsEl = document.getElementById('db-counts');
const userEl = document.getElementById('db-user'); const userEl = document.getElementById('db-user');
if (data.connected && data.db_user) {
if (data.connected) { userEl.innerHTML = '<span class="text-gray-500">@</span>' + data.db_user;
statusEl.innerHTML = '<span class="text-green-400">БД: подключено</span>';
if (data.db_user) {
userEl.innerHTML = '<span class="text-gray-500">@</span>' + data.db_user;
}
} else {
statusEl.innerHTML = '<span class="text-red-400">БД: ошибка - ' + data.error + '</span>';
} }
countsEl.textContent = 'lot: ' + data.lot_count + ' | lot_log: ' + data.lot_log_count + ' | metadata: ' + data.metadata_count;
} catch(e) { } catch(e) {
document.getElementById('db-status').innerHTML = '<span class="text-red-400">БД: нет связи</span>'; // ignore
} }
} }
@@ -262,7 +352,7 @@
// Call functions immediately to ensure they run even before DOMContentLoaded // Call functions immediately to ensure they run even before DOMContentLoaded
// This ensures username and admin link are visible ASAP // This ensures username and admin link are visible ASAP
checkDbStatus(); loadDBUser();
checkWritePermission(); checkWritePermission();
// Load last sync time - removed since dropdown is gone // Load last sync time - removed since dropdown is gone

View File

@@ -22,6 +22,11 @@
</button> </button>
</div> </div>
<div class="max-w-md">
<input id="configs-search" type="text" placeholder="Поиск квоты по названию"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div id="pricelist-badge" class="mt-4 text-sm text-gray-600 hidden"> <div id="pricelist-badge" class="mt-4 text-sm text-gray-600 hidden">
<span class="bg-blue-100 text-blue-800 px-2 py-1 rounded-full"> <span class="bg-blue-100 text-blue-800 px-2 py-1 rounded-full">
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -56,6 +61,19 @@
<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>
<input id="create-project-input"
list="create-project-options"
placeholder="Начните вводить название проекта"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<datalist id="create-project-options"></datalist>
<div class="mt-2 flex justify-between items-center gap-3">
<button type="button" onclick="clearCreateProjectInput()" class="text-sm text-gray-600 hover:text-gray-800">
Без проекта
</button>
</div>
</div>
</div> </div>
<div class="flex justify-end space-x-3 mt-6"> <div class="flex justify-end space-x-3 mt-6">
@@ -119,12 +137,67 @@
</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>" не найден. <span id="create-project-on-move-description">Создать и привязать квоту?</span></p>
<div class="flex justify-end space-x-3">
<button onclick="closeCreateProjectOnMoveModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
<button id="create-project-on-move-confirm-btn" onclick="confirmCreateProjectOnMove()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Создать и привязать</button>
</div>
</div>
</div>
<script> <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 configStatusMode = 'active';
let configsSearch = '';
let projectsCache = [];
let projectNameByUUID = {};
let pendingMoveConfigUUID = '';
let pendingMoveProjectName = '';
let pendingCreateConfigName = '';
let pendingCreateProjectName = '';
function renderConfigs(configs) { function renderConfigs(configs) {
const emptyText = configStatusMode === 'archived' const emptyText = configStatusMode === 'archived'
@@ -139,6 +212,7 @@ function renderConfigs(configs) {
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">Автор</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>';
@@ -152,6 +226,9 @@ function renderConfigs(configs) {
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 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 = '—';
@@ -162,6 +239,19 @@ function renderConfigs(configs) {
html += '<tr class="hover:bg-gray-50">'; html += '<tr class="hover:bg-gray-50">';
html += '<td class="px-4 py-3 text-sm text-gray-500">' + date + '</td>'; html += '<td class="px-4 py-3 text-sm text-gray-500">' + date + '</td>';
if (configStatusMode === 'archived') {
if (c.project_uuid) {
html += '<td class="px-4 py-3 text-sm"><a href="/projects/' + c.project_uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(projectName) + '</a></td>';
} else {
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(projectName) + '</td>';
}
} else {
if (c.project_uuid) {
html += '<td class="px-4 py-3 text-sm"><a href="/projects/' + c.project_uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(projectName) + '</a></td>';
} else {
html += '<td class="px-4 py-3 text-sm text-gray-700">' + escapeHtml(projectName) + '</td>';
}
}
if (configStatusMode === 'archived') { if (configStatusMode === 'archived') {
html += '<td class="px-4 py-3 text-sm font-medium text-gray-700">' + escapeHtml(c.name) + '</td>'; html += '<td class="px-4 py-3 text-sm font-medium text-gray-700">' + escapeHtml(c.name) + '</td>';
} else { } else {
@@ -179,6 +269,11 @@ function renderConfigs(configs) {
html += '</svg>'; html += '</svg>';
html += '</button>'; html += '</button>';
} else { } else {
html += '<button onclick="openMoveProjectModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\', \'' + (c.project_uuid || '') + '\')" class="text-indigo-600 hover:text-indigo-800" title="Перенести в проект">';
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0l-3 3m3-3l3 3m7 1v12m0 0l-3-3m3 3l3-3"></path>';
html += '</svg>';
html += '</button>';
html += '<button onclick="openCloneModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-green-600 hover:text-green-800" title="Копировать">'; html += '<button onclick="openCloneModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-green-600 hover:text-green-800" title="Копировать">';
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">'; html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>'; html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>';
@@ -320,6 +415,7 @@ async function cloneConfig() {
function openCreateModal() { function openCreateModal() {
document.getElementById('opportunity-number').value = ''; document.getElementById('opportunity-number').value = '';
document.getElementById('create-project-input').value = '';
document.getElementById('create-modal').classList.remove('hidden'); document.getElementById('create-modal').classList.remove('hidden');
document.getElementById('create-modal').classList.add('flex'); document.getElementById('create-modal').classList.add('flex');
document.getElementById('opportunity-number').focus(); document.getElementById('opportunity-number').focus();
@@ -338,6 +434,25 @@ async function createConfig() {
return; return;
} }
const projectName = document.getElementById('create-project-input').value.trim();
let projectUUID = '';
if (projectName) {
const existingProject = projectsCache.find(p => p.is_active && p.name.toLowerCase() === projectName.toLowerCase());
if (existingProject) {
projectUUID = existingProject.uuid;
} else {
pendingCreateConfigName = name;
pendingCreateProjectName = projectName;
openCreateProjectOnCreateModal(projectName);
return;
}
}
await createConfigWithProject(name, projectUUID);
}
async function createConfigWithProject(name, projectUUID) {
try { try {
const resp = await fetch('/api/configs', { const resp = await fetch('/api/configs', {
method: 'POST', method: 'POST',
@@ -348,20 +463,198 @@ async function createConfig() {
name: name, name: name,
items: [], items: [],
notes: '', notes: '',
server_count: 1 server_count: 1,
project_uuid: projectUUID || null
}) })
}); });
const config = await resp.json();
if (!resp.ok) { if (!resp.ok) {
const err = await resp.json(); alert('Ошибка: ' + (config.error || 'Не удалось создать'));
alert('Ошибка: ' + (err.error || 'Не удалось создать')); return false;
}
window.location.href = '/configurator?uuid=' + config.uuid;
return true;
} catch(e) {
alert('Ошибка создания конфигурации');
return false;
}
}
function openMoveProjectModal(uuid, configName, currentProjectUUID) {
document.getElementById('move-project-uuid').value = uuid;
document.getElementById('move-project-config-name').textContent = configName;
const input = document.getElementById('move-project-input');
const options = document.getElementById('move-project-options');
options.innerHTML = '';
projectsCache.forEach(project => {
if (!project.is_active) return;
const option = document.createElement('option');
option.value = project.name;
options.appendChild(option);
});
if (currentProjectUUID && projectNameByUUID[currentProjectUUID]) {
input.value = projectNameByUUID[currentProjectUUID];
} else {
input.value = '';
}
document.getElementById('move-project-modal').classList.remove('hidden');
document.getElementById('move-project-modal').classList.add('flex');
}
function closeMoveProjectModal() {
document.getElementById('move-project-modal').classList.add('hidden');
document.getElementById('move-project-modal').classList.remove('flex');
}
async function confirmMoveProject() {
const uuid = document.getElementById('move-project-uuid').value;
const projectName = document.getElementById('move-project-input').value.trim();
if (!uuid) return;
let projectUUID = '';
if (projectName) {
const existingProject = projectsCache.find(p => p.is_active && p.name.toLowerCase() === projectName.toLowerCase());
if (existingProject) {
projectUUID = existingProject.uuid;
} else {
pendingMoveConfigUUID = uuid;
pendingMoveProjectName = projectName;
openCreateProjectOnMoveModal(projectName);
return;
}
}
await moveConfigToProject(uuid, projectUUID);
}
function clearMoveProjectInput() {
document.getElementById('move-project-input').value = '';
}
function clearCreateProjectInput() {
document.getElementById('create-project-input').value = '';
}
function openCreateProjectOnMoveModal(projectName) {
document.getElementById('create-project-on-move-name').textContent = projectName;
document.getElementById('create-project-on-move-description').textContent = 'Создать и привязать квоту?';
document.getElementById('create-project-on-move-confirm-btn').textContent = 'Создать и привязать';
document.getElementById('create-project-on-move-modal').classList.remove('hidden');
document.getElementById('create-project-on-move-modal').classList.add('flex');
}
function openCreateProjectOnCreateModal(projectName) {
document.getElementById('create-project-on-move-name').textContent = projectName;
document.getElementById('create-project-on-move-description').textContent = 'Создать и использовать для новой конфигурации?';
document.getElementById('create-project-on-move-confirm-btn').textContent = 'Создать и использовать';
document.getElementById('create-project-on-move-modal').classList.remove('hidden');
document.getElementById('create-project-on-move-modal').classList.add('flex');
}
function closeCreateProjectOnMoveModal() {
document.getElementById('create-project-on-move-modal').classList.add('hidden');
document.getElementById('create-project-on-move-modal').classList.remove('flex');
pendingMoveConfigUUID = '';
pendingMoveProjectName = '';
pendingCreateConfigName = '';
pendingCreateProjectName = '';
}
async function confirmCreateProjectOnMove() {
if (pendingCreateConfigName && pendingCreateProjectName) {
const configName = pendingCreateConfigName;
const projectName = pendingCreateProjectName;
try {
const createResp = await fetch('/api/projects', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ name: projectName })
});
if (!createResp.ok) {
const err = await createResp.json();
alert('Не удалось создать проект: ' + (err.error || 'ошибка'));
return;
}
const newProject = await createResp.json();
pendingCreateConfigName = '';
pendingCreateProjectName = '';
await loadProjectsForConfigUI();
const created = await createConfigWithProject(configName, newProject.uuid);
if (created) {
closeCreateProjectOnMoveModal();
} else {
closeCreateProjectOnMoveModal();
document.getElementById('create-project-input').value = projectName;
}
} catch (e) {
alert('Ошибка создания проекта');
}
return;
}
const configUUID = pendingMoveConfigUUID;
const projectName = pendingMoveProjectName;
if (!configUUID || !projectName) {
closeCreateProjectOnMoveModal();
return;
}
try {
const createResp = await fetch('/api/projects', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ name: projectName })
});
if (!createResp.ok) {
const err = await createResp.json();
alert('Не удалось создать проект: ' + (err.error || 'ошибка'));
return; return;
} }
const config = await resp.json(); const newProject = await createResp.json();
window.location.href = '/configurator?uuid=' + config.uuid; pendingMoveConfigUUID = '';
} catch(e) { pendingMoveProjectName = '';
alert('Ошибка создания конфигурации'); await loadProjectsForConfigUI();
document.getElementById('move-project-input').value = projectName;
const moved = await moveConfigToProject(configUUID, newProject.uuid);
if (moved) {
closeCreateProjectOnMoveModal();
} else {
closeCreateProjectOnMoveModal();
}
} catch (e) {
alert('Ошибка создания проекта');
}
}
async function moveConfigToProject(uuid, projectUUID) {
try {
const resp = await fetch('/api/configs/' + uuid + '/project', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ project_uuid: projectUUID })
});
if (!resp.ok) {
const err = await resp.json();
alert('Не удалось перенести квоту: ' + (err.error || 'ошибка'));
return false;
}
closeMoveProjectModal();
await loadProjectsForConfigUI();
await loadConfigs();
return true;
} catch (e) {
alert('Ошибка переноса квоты');
return false;
} }
} }
@@ -384,12 +677,26 @@ document.getElementById('clone-modal').addEventListener('click', function(e) {
} }
}); });
document.getElementById('move-project-modal').addEventListener('click', function(e) {
if (e.target === this) {
closeMoveProjectModal();
}
});
document.getElementById('create-project-on-move-modal').addEventListener('click', function(e) {
if (e.target === this) {
closeCreateProjectOnMoveModal();
}
});
// Close modal on Escape key // 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();
} }
}); });
@@ -461,7 +768,7 @@ function applyStatusModeUI() {
// 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 + '&status=' + configStatusMode); 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 =
@@ -512,12 +819,46 @@ async function importConfigsFromServer() {
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
applyStatusModeUI(); applyStatusModeUI();
loadConfigs(); 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 || []);
projectsCache.forEach(project => {
projectNameByUUID[project.uuid] = project.name;
});
const createOptions = document.getElementById('create-project-options');
if (createOptions) {
createOptions.innerHTML = '';
projectsCache.forEach(project => {
if (!project.is_active) return;
const option = document.createElement('option');
option.value = project.name;
createOptions.appendChild(option);
});
}
} catch (e) {
// keep default behavior without project selection data
}
}
async function loadLatestPricelistVersion() { async function loadLatestPricelistVersion() {
try { try {
const resp = await fetch('/api/pricelists/latest'); const resp = await fetch('/api/pricelists/latest');

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,14 @@
</span> </span>
{{end}} {{end}}
{{if gt .PendingCount 0}} {{if .IsBlocked}}
<span class="bg-red-100 text-red-800 px-2 py-0.5 rounded-full text-xs font-medium flex items-center gap-1 cursor-pointer" title="{{.BlockedReason}}" onclick="openSyncModal()">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-7.938 4h15.876c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L2.33 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
!
</span>
{{else if gt .PendingCount 0}}
<span class="bg-yellow-100 text-yellow-800 px-2 py-0.5 rounded-full text-xs font-medium flex items-center gap-1 cursor-pointer" onclick="openSyncModal()"> <span class="bg-yellow-100 text-yellow-800 px-2 py-0.5 rounded-full text-xs font-medium flex items-center gap-1 cursor-pointer" onclick="openSyncModal()">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>

View File

@@ -57,13 +57,15 @@
<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> <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>
<th id="th-qty" class="hidden px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Доступно</th>
<th id="th-partnumbers" class="hidden px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Partnumbers</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Цена, $</th> <th class="px-6 py-3 text-right 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> </tr>
</thead> </thead>
<tbody id="items-body" class="bg-white divide-y divide-gray-200"> <tbody id="items-body" class="bg-white divide-y divide-gray-200">
<tr> <tr>
<td colspan="5" class="px-6 py-4 text-center text-gray-500">Загрузка...</td> <td colspan="7" class="px-6 py-4 text-center text-gray-500">Загрузка...</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -80,6 +82,7 @@
let currentPage = 1; let currentPage = 1;
let searchQuery = ''; let searchQuery = '';
let searchTimeout = null; let searchTimeout = null;
let currentSource = '';
async function loadPricelistInfo() { async function loadPricelistInfo() {
try { try {
@@ -87,6 +90,8 @@
if (!resp.ok) throw new Error('Pricelist not found'); if (!resp.ok) throw new Error('Pricelist not found');
const pl = await resp.json(); const pl = await resp.json();
currentSource = pl.source || '';
toggleWarehouseColumns();
document.getElementById('page-title').textContent = `Прайслист ${pl.version}`; document.getElementById('page-title').textContent = `Прайслист ${pl.version}`;
document.getElementById('pl-version').textContent = pl.version; document.getElementById('pl-version').textContent = pl.version;
@@ -128,13 +133,15 @@
const resp = await fetch(url); const resp = await fetch(url);
const data = await resp.json(); const data = await resp.json();
currentSource = data.source || currentSource;
toggleWarehouseColumns();
renderItems(data.items || []); renderItems(data.items || []);
renderItemsPagination(data.total, data.page, data.per_page); renderItemsPagination(data.total, data.page, data.per_page);
} catch (e) { } catch (e) {
document.getElementById('items-body').innerHTML = ` document.getElementById('items-body').innerHTML = `
<tr> <tr>
<td colspan="5" class="px-6 py-4 text-center text-red-500"> <td colspan="${itemsColspan()}" class="px-6 py-4 text-center text-red-500">
Ошибка загрузки: ${e.message} Ошибка загрузки: ${e.message}
</td> </td>
</tr> </tr>
@@ -142,17 +149,50 @@
} }
} }
function isWarehouseSource() {
return (currentSource || '').toLowerCase() === 'warehouse';
}
function itemsColspan() {
return isWarehouseSource() ? 7 : 5;
}
function toggleWarehouseColumns() {
const visible = isWarehouseSource();
document.getElementById('th-qty').classList.toggle('hidden', !visible);
document.getElementById('th-partnumbers').classList.toggle('hidden', !visible);
}
function formatQty(qty) {
if (typeof qty !== 'number') return '—';
if (Number.isInteger(qty)) return qty.toString();
return qty.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 3 });
}
function escapeHtml(text) {
if (text === null || text === undefined) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function formatPriceSettings(item) { function formatPriceSettings(item) {
// Format price settings to match admin pricing interface style // Format price settings to match admin pricing interface style
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('Мед');
} }
@@ -185,7 +225,7 @@
if (items.length === 0) { if (items.length === 0) {
document.getElementById('items-body').innerHTML = ` document.getElementById('items-body').innerHTML = `
<tr> <tr>
<td colspan="5" class="px-6 py-4 text-center text-gray-500"> <td colspan="${itemsColspan()}" class="px-6 py-4 text-center text-gray-500">
${searchQuery ? 'Ничего не найдено' : 'Позиции не найдены'} ${searchQuery ? 'Ничего не найдено' : 'Позиции не найдены'}
</td> </td>
</tr> </tr>
@@ -193,10 +233,13 @@
return; return;
} }
const showWarehouse = isWarehouseSource();
const html = items.map(item => { const html = items.map(item => {
const price = item.price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const price = item.price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const description = item.lot_description || '-'; const description = item.lot_description || '-';
const truncatedDesc = description.length > 60 ? description.substring(0, 60) + '...' : description; const truncatedDesc = description.length > 60 ? description.substring(0, 60) + '...' : description;
const qty = formatQty(item.available_qty);
const partnumbers = Array.isArray(item.partnumbers) && item.partnumbers.length > 0 ? item.partnumbers.join(', ') : '—';
return ` return `
<tr class="hover:bg-gray-50"> <tr class="hover:bg-gray-50">
@@ -207,6 +250,8 @@
<span class="px-2 py-1 text-xs bg-gray-100 rounded">${item.category || '-'}</span> <span class="px-2 py-1 text-xs bg-gray-100 rounded">${item.category || '-'}</span>
</td> </td>
<td class="px-6 py-3 text-sm text-gray-500" title="${description}">${truncatedDesc}</td> <td class="px-6 py-3 text-sm text-gray-500" title="${description}">${truncatedDesc}</td>
${showWarehouse ? `<td class="px-6 py-3 whitespace-nowrap text-right font-mono">${qty}</td>` : ''}
${showWarehouse ? `<td class="px-6 py-3 text-sm text-gray-600" title="${escapeHtml(partnumbers)}">${escapeHtml(partnumbers)}</td>` : ''}
<td class="px-6 py-3 whitespace-nowrap text-right font-mono">${price}</td> <td class="px-6 py-3 whitespace-nowrap text-right font-mono">${price}</td>
<td class="px-6 py-3 whitespace-nowrap text-sm"><span class="text-xs bg-gray-100 px-2 py-1 rounded">${formatPriceSettings(item)}</span></td> <td class="px-6 py-3 whitespace-nowrap text-sm"><span class="text-xs bg-gray-100 px-2 py-1 rounded">${formatPriceSettings(item)}</span></td>
</tr> </tr>

View File

@@ -0,0 +1,501 @@
{{define "title"}}Проект - QuoteForge{{end}}
{{define "content"}}
<div class="space-y-4">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3">
<a href="/projects" class="text-gray-500 hover:text-gray-700" title="Назад к проектам">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
</a>
<h1 class="text-2xl font-bold" id="project-title">Проект</h1>
</div>
</div>
<div id="action-buttons" class="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3">
<button onclick="openCreateModal()" class="py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
+ Создать новую квоту
</button>
<button onclick="openImportModal()" class="py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 font-medium">
Импорт квоты
</button>
</div>
<div class="mt-2">
<a id="tracker-link" href="https://tracker.yandex.ru/OPS-1933" target="_blank" rel="noopener noreferrer" class="text-sm text-blue-600 hover:text-blue-800 hover:underline">
открыть в трекере
</a>
</div>
<div class="mt-4 inline-flex rounded-lg border border-gray-200 overflow-hidden">
<button id="status-active-btn" onclick="setConfigStatusMode('active')" class="px-4 py-2 text-sm font-medium bg-blue-600 text-white">
Активные
</button>
<button id="status-archived-btn" onclick="setConfigStatusMode('archived')" class="px-4 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200">
Архив
</button>
</div>
<div id="configs-list">
<div class="text-center py-8 text-gray-500">Загрузка...</div>
</div>
</div>
<div id="create-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<h2 class="text-xl font-semibold mb-4">Новая квота в проекте</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Название квоты</label>
<input type="text" id="create-name" placeholder="Например: OPP-2026-001"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button onclick="closeCreateModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
<button onclick="createConfig()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Создать</button>
</div>
</div>
</div>
<div id="rename-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<h2 class="text-xl font-semibold mb-4">Переименовать квоту</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Новое название</label>
<input type="text" id="rename-input"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<input type="hidden" id="rename-uuid">
</div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button onclick="closeRenameModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
<button onclick="renameConfig()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Сохранить</button>
</div>
</div>
</div>
<div id="clone-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<h2 class="text-xl font-semibold mb-4">Копировать квоту</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Название копии</label>
<input type="text" id="clone-input"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<input type="hidden" id="clone-uuid">
</div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button onclick="closeCloneModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
<button onclick="cloneConfig()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">Копировать</button>
</div>
</div>
</div>
<div id="import-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<h2 class="text-xl font-semibold mb-4">Импорт квоты в проект</h2>
<div class="space-y-3">
<label class="block text-sm font-medium text-gray-700">Квота</label>
<input id="import-config-input"
list="import-config-options"
placeholder="Начните вводить название квоты"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<datalist id="import-config-options"></datalist>
<div class="text-xs text-gray-500">Квота будет перемещена в текущий проект.</div>
</div>
<div class="flex justify-end gap-2 mt-6">
<button onclick="closeImportModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
<button onclick="importConfigToProject()" class="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700">Импортировать</button>
</div>
</div>
</div>
<script>
const projectUUID = '{{.ProjectUUID}}';
let configStatusMode = 'active';
let project = null;
let allConfigs = [];
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
}
function resolveProjectTrackerURL(projectData) {
if (!projectData) return '';
const explicitURL = (projectData.tracker_url || '').trim();
return explicitURL;
}
function setConfigStatusMode(mode) {
if (mode !== 'active' && mode !== 'archived') return;
configStatusMode = mode;
applyStatusModeUI();
loadConfigs();
}
function applyStatusModeUI() {
const activeBtn = document.getElementById('status-active-btn');
const archivedBtn = document.getElementById('status-archived-btn');
const actionButtons = document.getElementById('action-buttons');
if (configStatusMode === 'archived') {
activeBtn.className = 'px-4 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50';
archivedBtn.className = 'px-4 py-2 text-sm font-medium bg-blue-600 text-white border-l border-gray-200';
actionButtons.classList.add('hidden');
} else {
activeBtn.className = 'px-4 py-2 text-sm font-medium bg-blue-600 text-white';
archivedBtn.className = 'px-4 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200';
actionButtons.classList.remove('hidden');
}
}
function renderConfigs(configs) {
const emptyText = configStatusMode === 'archived' ? 'Архив пуст' : 'Нет квот в проекте';
if (configs.length === 0) {
document.getElementById('configs-list').innerHTML =
'<div class="bg-white rounded-lg shadow p-8 text-center text-gray-500">' + emptyText + '</div>';
return;
}
let totalSum = 0;
let html = '<div class="bg-white rounded-lg shadow overflow-hidden"><table class="w-full">';
html += '<thead class="bg-gray-50"><tr>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Название</th>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Цена (за 1 шт)</th>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Кол-во</th>';
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Сумма</th>';
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
html += '</tr></thead><tbody class="divide-y">';
configs.forEach(c => {
const date = new Date(c.created_at).toLocaleDateString('ru-RU');
const total = c.total_price || 0;
const serverCount = c.server_count || 1;
const author = c.owner_username || (c.user && c.user.username) || '—';
const unitPrice = serverCount > 0 ? (total / serverCount) : 0;
totalSum += total;
html += '<tr class="hover:bg-gray-50">';
html += '<td class="px-4 py-3 text-sm text-gray-500">' + date + '</td>';
if (configStatusMode === 'archived') {
html += '<td class="px-4 py-3 text-sm font-medium text-gray-700">' + escapeHtml(c.name) + '</td>';
} else {
html += '<td class="px-4 py-3 text-sm font-medium"><a href="/configurator?uuid=' + c.uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(c.name) + '</a></td>';
}
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(author) + '</td>';
html += '<td class="px-4 py-3 text-sm text-gray-500">$' + unitPrice.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
html += '<td class="px-4 py-3 text-sm text-gray-500">' + serverCount + '</td>';
html += '<td class="px-4 py-3 text-sm text-right">$' + total.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
html += '<td class="px-4 py-3 text-sm text-right space-x-2">';
if (configStatusMode === 'archived') {
html += '<button onclick="reactivateConfig(\'' + c.uuid + '\')" class="text-emerald-600 hover:text-emerald-800" title="Восстановить">';
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg></button>';
} else {
html += '<button onclick="openCloneModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-green-600 hover:text-green-800" title="Копировать">';
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg></button>';
html += '<button onclick="openRenameModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-blue-600 hover:text-blue-800" title="Переименовать">';
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg></button>';
html += '<button onclick="deleteConfig(\'' + c.uuid + '\')" class="text-red-600 hover:text-red-800" title="В архив">';
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg></button>';
}
html += '</td></tr>';
});
html += '</tbody>';
html += '<tfoot class="bg-gray-50 border-t">';
html += '<tr>';
html += '<td class="px-4 py-3 text-sm font-medium text-gray-700" colspan="4">Итого по проекту</td>';
html += '<td class="px-4 py-3 text-sm text-gray-700">' + configs.length + '</td>';
html += '<td class="px-4 py-3 text-sm text-right font-semibold text-gray-900">$' + totalSum.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
html += '<td class="px-4 py-3"></td>';
html += '</tr>';
html += '</tfoot>';
html += '</table></div>';
document.getElementById('configs-list').innerHTML = html;
}
async function loadProject() {
const resp = await fetch('/api/projects/' + projectUUID);
if (!resp.ok) {
document.getElementById('configs-list').innerHTML = '<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Проект не найден</div>';
return false;
}
project = await resp.json();
document.getElementById('project-title').textContent = project.name;
const trackerLink = document.getElementById('tracker-link');
if (trackerLink) {
if (project && project.is_system) {
trackerLink.classList.add('hidden');
return true;
}
const trackerURL = resolveProjectTrackerURL(project);
if (trackerURL) {
trackerLink.href = trackerURL;
trackerLink.classList.remove('hidden');
} else {
trackerLink.classList.add('hidden');
}
}
return true;
}
async function loadConfigs() {
try {
const resp = await fetch('/api/projects/' + projectUUID + '/configs?status=' + configStatusMode);
if (!resp.ok) {
document.getElementById('configs-list').innerHTML =
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки</div>';
return;
}
const data = await resp.json();
allConfigs = (data.configurations || []);
renderConfigs(allConfigs);
} catch (e) {
document.getElementById('configs-list').innerHTML =
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки</div>';
}
}
function openCreateModal() {
document.getElementById('create-name').value = '';
document.getElementById('create-modal').classList.remove('hidden');
document.getElementById('create-modal').classList.add('flex');
document.getElementById('create-name').focus();
}
function closeCreateModal() {
document.getElementById('create-modal').classList.add('hidden');
document.getElementById('create-modal').classList.remove('flex');
}
async function createConfig() {
const name = document.getElementById('create-name').value.trim();
if (!name) {
alert('Введите название');
return;
}
const resp = await fetch('/api/projects/' + projectUUID + '/configs', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: name, items: [], notes: '', server_count: 1})
});
if (!resp.ok) {
alert('Не удалось создать квоту');
return;
}
closeCreateModal();
loadConfigs();
}
async function deleteConfig(uuid) {
if (!confirm('Переместить квоту в архив?')) return;
await fetch('/api/configs/' + uuid, {method: 'DELETE'});
loadConfigs();
}
async function reactivateConfig(uuid) {
const resp = await fetch('/api/configs/' + uuid + '/reactivate', {method: 'POST'});
if (!resp.ok) {
alert('Не удалось восстановить квоту');
return;
}
const moved = await fetch('/api/configs/' + uuid + '/project', {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({project_uuid: projectUUID})
});
if (!moved.ok) {
alert('Квота восстановлена, но не удалось вернуть в проект');
}
loadConfigs();
}
function openRenameModal(uuid, currentName) {
document.getElementById('rename-uuid').value = uuid;
document.getElementById('rename-input').value = currentName;
document.getElementById('rename-modal').classList.remove('hidden');
document.getElementById('rename-modal').classList.add('flex');
}
function closeRenameModal() {
document.getElementById('rename-modal').classList.add('hidden');
document.getElementById('rename-modal').classList.remove('flex');
}
async function renameConfig() {
const uuid = document.getElementById('rename-uuid').value;
const name = document.getElementById('rename-input').value.trim();
if (!name) {
alert('Введите название');
return;
}
const resp = await fetch('/api/configs/' + uuid + '/rename', {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: name})
});
if (!resp.ok) {
alert('Не удалось переименовать');
return;
}
closeRenameModal();
loadConfigs();
}
function openCloneModal(uuid, currentName) {
document.getElementById('clone-uuid').value = uuid;
document.getElementById('clone-input').value = currentName + ' (копия)';
document.getElementById('clone-modal').classList.remove('hidden');
document.getElementById('clone-modal').classList.add('flex');
}
function closeCloneModal() {
document.getElementById('clone-modal').classList.add('hidden');
document.getElementById('clone-modal').classList.remove('flex');
}
async function cloneConfig() {
const uuid = document.getElementById('clone-uuid').value;
const name = document.getElementById('clone-input').value.trim();
if (!name) {
alert('Введите название');
return;
}
const resp = await fetch('/api/projects/' + projectUUID + '/configs/' + uuid + '/clone', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: name})
});
if (!resp.ok) {
alert('Не удалось скопировать');
return;
}
closeCloneModal();
loadConfigs();
}
function openImportModal() {
const activeOther = allConfigs.length ? null : null; // no-op placeholder
void activeOther;
document.getElementById('import-config-input').value = '';
document.getElementById('import-config-options').innerHTML = '';
loadImportOptions();
document.getElementById('import-modal').classList.remove('hidden');
document.getElementById('import-modal').classList.add('flex');
}
function closeImportModal() {
document.getElementById('import-modal').classList.add('hidden');
document.getElementById('import-modal').classList.remove('flex');
}
async function loadImportOptions() {
const resp = await fetch('/api/configs?page=1&per_page=500&status=active');
if (!resp.ok) return;
const data = await resp.json();
const options = document.getElementById('import-config-options');
options.innerHTML = '';
(data.configurations || [])
.filter(c => c.project_uuid !== projectUUID)
.forEach(c => {
const opt = document.createElement('option');
opt.value = c.name;
options.appendChild(opt);
});
}
async function importConfigToProject() {
const query = document.getElementById('import-config-input').value.trim();
if (!query) {
alert('Выберите квоту');
return;
}
const resp = await fetch('/api/configs?page=1&per_page=500&status=active');
if (!resp.ok) {
alert('Не удалось загрузить список квот');
return;
}
const data = await resp.json();
const sourceConfigs = (data.configurations || []).filter(c => c.project_uuid !== projectUUID);
let targets = [];
if (query.includes('*')) {
targets = sourceConfigs.filter(c => wildcardMatch(c.name || '', query));
} else {
const found = sourceConfigs.find(c => (c.name || '').toLowerCase() === query.toLowerCase());
if (found) {
targets = [found];
}
}
if (!targets.length) {
alert('Подходящие квоты не найдены');
return;
}
let moved = 0;
let failed = 0;
for (const cfg of targets) {
const move = await fetch('/api/configs/' + cfg.uuid + '/project', {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({project_uuid: projectUUID})
});
if (move.ok) {
moved++;
} else {
failed++;
}
}
if (!moved) {
alert('Не удалось импортировать квоты');
return;
}
closeImportModal();
await loadConfigs();
if (targets.length > 1 || failed > 0) {
alert('Импорт завершен: перенесено ' + moved + ', ошибок ' + failed);
}
}
function wildcardMatch(value, pattern) {
const escaped = pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '.*');
const regex = new RegExp('^' + escaped + '$', 'i');
return regex.test(value);
}
document.getElementById('create-modal').addEventListener('click', function(e) { if (e.target === this) closeCreateModal(); });
document.getElementById('rename-modal').addEventListener('click', function(e) { if (e.target === this) closeRenameModal(); });
document.getElementById('clone-modal').addEventListener('click', function(e) { if (e.target === this) closeCloneModal(); });
document.getElementById('import-modal').addEventListener('click', function(e) { if (e.target === this) closeImportModal(); });
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeCreateModal();
closeRenameModal();
closeCloneModal();
closeImportModal();
}
});
document.addEventListener('DOMContentLoaded', async function() {
applyStatusModeUI();
const ok = await loadProject();
if (!ok) return;
await loadConfigs();
});
</script>
{{end}}
{{template "base" .}}

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

@@ -0,0 +1,426 @@
{{define "title"}}Мои проекты - QuoteForge{{end}}
{{define "content"}}
<div class="space-y-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<h1 class="text-2xl font-bold">Мои проекты</h1>
<div class="flex items-center gap-2">
<a href="/configs" class="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300">
Все конфигурации
</a>
<button onclick="openCreateProjectModal()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
+ Новый проект
</button>
</div>
</div>
<div class="inline-flex rounded-lg border border-gray-200 overflow-hidden">
<button id="status-active-btn" onclick="setStatus('active')" class="px-4 py-2 text-sm font-medium bg-blue-600 text-white">Активные</button>
<button id="status-archived-btn" onclick="setStatus('archived')" class="px-4 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200">Архив</button>
</div>
<div class="max-w-md">
<input id="projects-search" type="text" placeholder="Поиск проекта по названию"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div id="projects-table" class="bg-white rounded-lg shadow p-4 text-gray-500">Загрузка...</div>
</div>
<div id="create-project-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<h2 class="text-xl font-semibold mb-4">Новый проект</h2>
<div class="space-y-4">
<div>
<label for="create-project-code" class="block text-sm font-medium text-gray-700 mb-1">Код проекта</label>
<input id="create-project-code" type="text" placeholder="Например: OPS-123"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label for="create-project-tracker-url" class="block text-sm font-medium text-gray-700 mb-1">Ссылка на трекер</label>
<input id="create-project-tracker-url" type="url" placeholder="https://tracker.yandex.ru/OPS-123"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
</div>
<div class="flex justify-end gap-2 mt-6">
<button type="button" onclick="closeCreateProjectModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
<button type="button" onclick="createProject()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Создать</button>
</div>
</div>
</div>
<script>
let status = 'active';
let projectsSearch = '';
let authorSearch = '';
let currentPage = 1;
let perPage = 10;
let sortField = 'created_at';
let sortDir = 'desc';
let createProjectTrackerManuallyEdited = false;
let createProjectLastAutoTrackerURL = '';
const trackerBaseURL = 'https://tracker.yandex.ru/';
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
}
function formatMoney(v) {
return '$' + (v || 0).toLocaleString('en-US', {minimumFractionDigits: 2});
}
function formatDateTime(value) {
if (!value) return '—';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '—';
return date.toLocaleString('ru-RU', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
function toggleSort(field) {
if (sortField === field) {
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
} else {
sortField = field;
sortDir = field === 'name' ? 'asc' : 'desc';
}
currentPage = 1;
loadProjects();
}
function setStatus(value) {
status = value;
currentPage = 1;
document.getElementById('status-active-btn').className = value === 'active'
? 'px-4 py-2 text-sm font-medium bg-blue-600 text-white'
: 'px-4 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50';
document.getElementById('status-archived-btn').className = value === 'archived'
? 'px-4 py-2 text-sm font-medium bg-blue-600 text-white border-l border-gray-200'
: 'px-4 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200';
loadProjects();
}
async function loadProjects() {
const root = document.getElementById('projects-table');
root.innerHTML = '<div class="text-gray-500">Загрузка...</div>';
let rows = [];
let total = 0;
let totalPages = 0;
let page = currentPage;
try {
const params = new URLSearchParams({
status: status,
search: projectsSearch,
author: authorSearch,
page: String(currentPage),
per_page: String(perPage),
sort: sortField,
dir: sortDir
});
const resp = await fetch('/api/projects?' + params.toString());
if (!resp.ok) {
throw new Error('HTTP ' + resp.status);
}
const data = await resp.json();
rows = data.projects || [];
total = data.total || 0;
totalPages = data.total_pages || 0;
page = data.page || currentPage;
currentPage = page;
} catch (e) {
root.innerHTML = '<div class="text-red-600">Ошибка загрузки проектов: ' + escapeHtml(String(e.message || e)) + '</div>';
return;
}
let html = '<div class="overflow-x-auto"><table class="w-full">';
html += '<thead class="bg-gray-50">';
html += '<tr>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">';
html += '<button type="button" onclick="toggleSort(\'name\')" class="inline-flex items-center gap-1 hover:text-gray-700">Название проекта';
if (sortField === 'name') {
html += sortDir === 'asc' ? ' <span>↑</span>' : ' <span>↓</span>';
}
html += '</button></th>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">';
html += '<button type="button" onclick="toggleSort(\'created_at\')" class="inline-flex items-center gap-1 hover:text-gray-700">Создан';
if (sortField === 'created_at') {
html += sortDir === 'asc' ? ' <span>↑</span>' : ' <span>↓</span>';
}
html += '</button></th>';
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Кол-во квот</th>';
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Сумма</th>';
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
html += '</tr>';
html += '<tr>';
html += '<th class="px-4 py-2"></th>';
html += '<th class="px-4 py-2"><input id="projects-author-filter" type="text" value="' + escapeHtml(authorSearch) + '" placeholder="Фильтр автора" class="w-full px-2 py-1 border rounded text-xs focus:ring-1 focus:ring-blue-500 focus:border-blue-500"></th>';
html += '<th class="px-4 py-2"></th>';
html += '<th class="px-4 py-2"></th>';
html += '<th class="px-4 py-2"></th>';
html += '<th class="px-4 py-2"></th>';
html += '</tr>';
html += '</thead><tbody class="divide-y">';
if (!rows.length) {
html += '<tr><td colspan="6" class="px-4 py-6 text-sm text-gray-500 text-center">Проектов нет</td></tr>';
}
rows.forEach(p => {
html += '<tr class="hover:bg-gray-50">';
html += '<td class="px-4 py-3 text-sm font-medium"><a class="text-blue-600 hover:underline" href="/projects/' + p.uuid + '">' + escapeHtml(p.name) + '</a></td>';
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(p.owner_username || '—') + '</td>';
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(formatDateTime(p.created_at)) + '</td>';
html += '<td class="px-4 py-3 text-sm text-right text-gray-700">' + (p.config_count || 0) + '</td>';
html += '<td class="px-4 py-3 text-sm text-right text-gray-700">' + formatMoney(p.total) + '</td>';
html += '<td class="px-4 py-3 text-sm text-right"><div class="inline-flex items-center gap-2">';
if (p.is_active) {
html += '<button onclick="copyProject(\'' + p.uuid + '\', \'' + escapeHtml(p.name).replace(/'/g, "\\'") + '\')" class="text-green-700 hover:text-green-900" title="Копировать">';
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>';
html += '</button>';
html += '<button onclick="renameProject(\'' + p.uuid + '\', \'' + escapeHtml(p.name).replace(/'/g, "\\'") + '\')" class="text-blue-700 hover:text-blue-900" title="Переименовать">';
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>';
html += '</button>';
html += '<button onclick="archiveProject(\'' + p.uuid + '\')" class="text-red-700 hover:text-red-900" title="Удалить (в архив)">';
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>';
html += '</button>';
html += '<button onclick="addConfigToProject(\'' + p.uuid + '\')" class="text-indigo-700 hover:text-indigo-900" title="Добавить квоту">';
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>';
html += '</button>';
} else {
html += '<button onclick="reactivateProject(\'' + p.uuid + '\')" class="text-emerald-700 hover:text-emerald-900" title="Восстановить">';
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>';
html += '</button>';
}
html += '</div></td>';
html += '</tr>';
});
html += '</tbody></table></div>';
if (totalPages > 1) {
html += '<div class="flex items-center justify-between mt-4 pt-4 border-t">';
html += '<div class="text-sm text-gray-600">Показано ' + rows.length + ' из ' + total + '</div>';
html += '<div class="inline-flex items-center gap-1">';
html += '<button type="button" onclick="goToPage(' + (page - 1) + ')" ' + (page <= 1 ? 'disabled' : '') + ' class="px-3 py-1 text-sm border rounded ' + (page <= 1 ? 'text-gray-300 border-gray-200 cursor-not-allowed' : 'text-gray-700 hover:bg-gray-50') + '">&larr;</button>';
const startPage = Math.max(1, page - 2);
const endPage = Math.min(totalPages, page + 2);
for (let i = startPage; i <= endPage; i++) {
html += '<button type="button" onclick="goToPage(' + i + ')" class="px-3 py-1 text-sm border rounded ' + (i === page ? 'bg-blue-600 text-white border-blue-600' : 'text-gray-700 border-gray-300 hover:bg-gray-50') + '">' + i + '</button>';
}
html += '<button type="button" onclick="goToPage(' + (page + 1) + ')" ' + (page >= totalPages ? 'disabled' : '') + ' class="px-3 py-1 text-sm border rounded ' + (page >= totalPages ? 'text-gray-300 border-gray-200 cursor-not-allowed' : 'text-gray-700 hover:bg-gray-50') + '">&rarr;</button>';
html += '</div>';
html += '</div>';
}
root.innerHTML = html;
const authorInput = document.getElementById('projects-author-filter');
if (authorInput) {
authorInput.addEventListener('input', function(e) {
authorSearch = (e.target.value || '').trim();
currentPage = 1;
loadProjects();
});
}
}
function goToPage(page) {
if (page < 1) return;
currentPage = page;
loadProjects();
}
function buildTrackerURLFromProjectCode(projectCode) {
const code = (projectCode || '').trim();
if (!code) return '';
return trackerBaseURL + encodeURIComponent(code);
}
function openCreateProjectModal() {
const codeInput = document.getElementById('create-project-code');
const trackerInput = document.getElementById('create-project-tracker-url');
codeInput.value = '';
trackerInput.value = '';
createProjectTrackerManuallyEdited = false;
createProjectLastAutoTrackerURL = '';
document.getElementById('create-project-modal').classList.remove('hidden');
document.getElementById('create-project-modal').classList.add('flex');
codeInput.focus();
}
function closeCreateProjectModal() {
document.getElementById('create-project-modal').classList.add('hidden');
document.getElementById('create-project-modal').classList.remove('flex');
}
function updateCreateProjectTrackerURL() {
const codeInput = document.getElementById('create-project-code');
const trackerInput = document.getElementById('create-project-tracker-url');
const generatedURL = buildTrackerURLFromProjectCode(codeInput.value);
if (!createProjectTrackerManuallyEdited || trackerInput.value.trim() === '' || trackerInput.value === createProjectLastAutoTrackerURL) {
trackerInput.value = generatedURL;
createProjectLastAutoTrackerURL = generatedURL;
}
}
async function createProject() {
const codeInput = document.getElementById('create-project-code');
const trackerInput = document.getElementById('create-project-tracker-url');
const name = (codeInput.value || '').trim();
if (!name) {
alert('Введите код проекта');
return;
}
const resp = await fetch('/api/projects', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
name: name,
tracker_url: (trackerInput.value || '').trim()
})
});
if (!resp.ok) {
alert('Не удалось создать проект');
return;
}
closeCreateProjectModal();
loadProjects();
}
async function renameProject(projectUUID, currentName) {
const name = prompt('Новое название проекта', currentName);
if (!name || !name.trim() || name.trim() === currentName) return;
const resp = await fetch('/api/projects/' + projectUUID, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: name.trim()})
});
if (!resp.ok) {
alert('Не удалось переименовать проект');
return;
}
loadProjects();
}
async function archiveProject(projectUUID) {
if (!confirm('Переместить проект в архив?')) return;
const resp = await fetch('/api/projects/' + projectUUID + '/archive', {method: 'POST'});
if (!resp.ok) {
alert('Не удалось архивировать проект');
return;
}
loadProjects();
}
async function reactivateProject(projectUUID) {
const resp = await fetch('/api/projects/' + projectUUID + '/reactivate', {method: 'POST'});
if (!resp.ok) {
alert('Не удалось восстановить проект');
return;
}
loadProjects();
}
async function addConfigToProject(projectUUID) {
const name = prompt('Название новой квоты');
if (!name || !name.trim()) return;
const resp = await fetch('/api/projects/' + projectUUID + '/configs', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: name.trim(), items: [], notes: '', server_count: 1})
});
if (!resp.ok) {
alert('Не удалось создать квоту');
return;
}
loadProjects();
}
async function copyProject(projectUUID, projectName) {
const newName = prompt('Название копии проекта', projectName + ' (копия)');
if (!newName || !newName.trim()) return;
const createResp = await fetch('/api/projects', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: newName.trim()})
});
if (!createResp.ok) {
alert('Не удалось создать копию проекта');
return;
}
const newProject = await createResp.json();
const listResp = await fetch('/api/projects/' + projectUUID + '/configs');
if (!listResp.ok) {
alert('Проект скопирован без квот (не удалось загрузить исходные квоты)');
loadProjects();
return;
}
const listData = await listResp.json();
const configs = listData.configurations || [];
for (const cfg of configs) {
await fetch('/api/projects/' + newProject.uuid + '/configs/' + cfg.uuid + '/clone', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: cfg.name})
});
}
loadProjects();
}
loadProjects();
document.getElementById('projects-search').addEventListener('input', function(e) {
projectsSearch = (e.target.value || '').trim();
currentPage = 1;
loadProjects();
});
document.getElementById('create-project-code').addEventListener('input', function() {
updateCreateProjectTrackerURL();
});
document.getElementById('create-project-tracker-url').addEventListener('input', function(e) {
createProjectTrackerManuallyEdited = (e.target.value || '').trim() !== createProjectLastAutoTrackerURL;
});
document.getElementById('create-project-code').addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
createProject();
}
});
document.getElementById('create-project-tracker-url').addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
createProject();
}
});
document.getElementById('create-project-modal').addEventListener('click', function(e) {
if (e.target === this) {
closeCreateProjectModal();
}
});
</script>
{{end}}
{{template "base" .}}