Compare commits
40 Commits
65871a8b04
...
v1.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84dda8cf0a | ||
|
|
abeb26d82d | ||
|
|
29edd73744 | ||
|
|
e8d0e28415 | ||
|
|
08feda9af6 | ||
|
|
af79b6f3bf | ||
|
|
bca82f9dc0 | ||
| 17969277e6 | |||
| 0dbfe45353 | |||
| f609d2ce35 | |||
| 593280de99 | |||
| eb8555c11a | |||
| 7523a7d887 | |||
| 95b5f8bf65 | |||
| b629af9742 | |||
| 72ff842f5d | |||
|
|
5f2969a85a | ||
|
|
eb8ac34d83 | ||
|
|
104a26d907 | ||
|
|
b965c6bb95 | ||
|
|
29035ddc5a | ||
|
|
2f0ac2f6d2 | ||
|
|
8a8ea10dc2 | ||
|
|
51e2d1fc83 | ||
|
|
3d5ab63970 | ||
|
|
c02a7eac73 | ||
|
|
651427e0dd | ||
|
|
f665e9b08c | ||
|
|
994eec53e7 | ||
|
|
2f3c20fea6 | ||
|
|
80ec7bc6b8 | ||
|
|
8e5c4f5a7c | ||
|
|
1744e6a3b8 | ||
|
|
726dccb07c | ||
|
|
38d7332a38 | ||
|
|
c0beed021c | ||
|
|
08b95c293c | ||
|
|
c418d6cfc3 | ||
|
|
548a256d04 | ||
|
|
77c00de97a |
5
.githooks/pre-commit
Executable file
5
.githooks/pre-commit
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(git rev-parse --show-toplevel)"
|
||||||
|
"$repo_root/scripts/check-secrets.sh"
|
||||||
30
.gitignore
vendored
30
.gitignore
vendored
@@ -1,5 +1,16 @@
|
|||||||
# QuoteForge
|
# QuoteForge
|
||||||
config.yaml
|
config.yaml
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.p12
|
||||||
|
*.pfx
|
||||||
|
*.crt
|
||||||
|
id_rsa
|
||||||
|
id_rsa.*
|
||||||
|
secrets.yaml
|
||||||
|
secrets.yml
|
||||||
|
|
||||||
# Local SQLite database (contains encrypted credentials)
|
# Local SQLite database (contains encrypted credentials)
|
||||||
/data/*.db
|
/data/*.db
|
||||||
@@ -16,6 +27,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
|
||||||
|
|||||||
198
CLAUDE.md
198
CLAUDE.md
@@ -1,163 +1,77 @@
|
|||||||
# 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
|
||||||
|
|
||||||
**Принцип работы:**
|
## Guardrails
|
||||||
- ВСЕ операции (CRUD) выполняются в SQLite
|
- Не возвращать в проект удалённые legacy-разделы: cron jobs, importer utility, admin pricing, alerts, stock import.
|
||||||
- При создании конфигурации:
|
- Runtime-конфиг читается из user state (`config.yaml`) или через `-config` / `QFS_CONFIG_PATH`; не хранить рабочий `config.yaml` в репозитории.
|
||||||
1. Если online → проверить новые прайслисты на сервере → скачать если есть
|
- `config.example.yaml` остаётся единственным шаблоном конфигурации в репо.
|
||||||
2. Далее работаем с local_pricelists (и online, и offline одинаково)
|
- Любые изменения в sync должны сохранять local-first поведение: локальные CRUD не блокируются из-за недоступности MariaDB.
|
||||||
- Background sync: push pending_changes → pull updates
|
|
||||||
|
|
||||||
**DONE:**
|
## Key SQLite Data
|
||||||
- ✅ Sync queue table (pending_changes) - `internal/localdb/models.go`
|
- `connection_settings`
|
||||||
- ✅ Model converters: MariaDB ↔ SQLite - `internal/localdb/converters.go`
|
- `local_components`
|
||||||
- ✅ LocalConfigurationService: все CRUD через SQLite - `internal/services/local_configuration.go`
|
- `local_pricelists`, `local_pricelist_items`
|
||||||
- ✅ Pre-create pricelist check: `SyncPricelistsIfNeeded()` - `internal/services/sync/service.go`
|
- `local_configurations`
|
||||||
- ✅ Push pending changes: `PushPendingChanges()` - sync service + handlers
|
- `local_projects`
|
||||||
- ✅ Sync API endpoints: `/api/sync/push`, `/pending/count`, `/pending`
|
- `pending_changes`
|
||||||
- ✅ 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
|
|
||||||
|
|||||||
@@ -1,178 +0,0 @@
|
|||||||
# Local-First Architecture Integration Guide
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
QuoteForge теперь поддерживает local-first архитектуру: приложение ВСЕГДА работает с SQLite (localdb), MariaDB используется только для синхронизации.
|
|
||||||
|
|
||||||
## Реализованные компоненты
|
|
||||||
|
|
||||||
### 1. Конвертеры моделей (`internal/localdb/converters.go`)
|
|
||||||
|
|
||||||
Конвертеры между MariaDB и SQLite моделями:
|
|
||||||
- `ConfigurationToLocal()` / `LocalToConfiguration()`
|
|
||||||
- `PricelistToLocal()` / `LocalToPricelist()`
|
|
||||||
- `ComponentToLocal()` / `LocalToComponent()`
|
|
||||||
|
|
||||||
### 2. LocalDB методы (`internal/localdb/localdb.go`)
|
|
||||||
|
|
||||||
Добавлены методы для работы с pending changes:
|
|
||||||
- `MarkChangesSynced(ids []int64)` - помечает изменения как синхронизированные
|
|
||||||
- `GetPendingCount()` - возвращает количество несинхронизированных изменений
|
|
||||||
|
|
||||||
### 3. Sync Service расширения (`internal/services/sync/service.go`)
|
|
||||||
|
|
||||||
Новые методы:
|
|
||||||
- `SyncPricelistsIfNeeded()` - проверяет и скачивает новые прайслисты при необходимости
|
|
||||||
- `PushPendingChanges()` - отправляет все pending changes на сервер
|
|
||||||
- `pushSingleChange()` - обрабатывает один pending change
|
|
||||||
- `pushConfigurationCreate/Update/Delete()` - специфичные методы для конфигураций
|
|
||||||
|
|
||||||
**ВАЖНО**: Конструктор изменен - теперь требует `ConfigurationRepository`:
|
|
||||||
```go
|
|
||||||
syncService := sync.NewService(pricelistRepo, configRepo, local)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. LocalConfigurationService (`internal/services/local_configuration.go`)
|
|
||||||
|
|
||||||
Новый сервис для работы с конфигурациями в local-first режиме:
|
|
||||||
- Все операции CRUD работают через SQLite
|
|
||||||
- Автоматически добавляет изменения в pending_changes
|
|
||||||
- При создании конфигурации (если online) проверяет новые прайслисты
|
|
||||||
|
|
||||||
```go
|
|
||||||
localConfigService := services.NewLocalConfigurationService(
|
|
||||||
localDB,
|
|
||||||
syncService,
|
|
||||||
quoteService,
|
|
||||||
isOnlineFunc,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Sync Handler расширения (`internal/handlers/sync.go`)
|
|
||||||
|
|
||||||
Новые endpoints:
|
|
||||||
- `POST /api/sync/push` - отправить pending changes на сервер
|
|
||||||
- `GET /api/sync/pending/count` - получить количество pending changes
|
|
||||||
- `GET /api/sync/pending` - получить список pending changes
|
|
||||||
|
|
||||||
## Интеграция
|
|
||||||
|
|
||||||
### Шаг 1: Обновить main.go
|
|
||||||
|
|
||||||
```go
|
|
||||||
// В cmd/qfs/main.go
|
|
||||||
syncService := sync.NewService(pricelistRepo, configRepo, local)
|
|
||||||
|
|
||||||
// Создать isOnline функцию
|
|
||||||
isOnlineFunc := func() bool {
|
|
||||||
sqlDB, err := db.DB()
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return sqlDB.Ping() == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Создать LocalConfigurationService
|
|
||||||
localConfigService := services.NewLocalConfigurationService(
|
|
||||||
local,
|
|
||||||
syncService,
|
|
||||||
quoteService,
|
|
||||||
isOnlineFunc,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Шаг 2: Обновить ConfigurationHandler
|
|
||||||
|
|
||||||
Заменить `ConfigurationService` на `LocalConfigurationService` в handlers:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Было:
|
|
||||||
configHandler := handlers.NewConfigurationHandler(configService, exportService)
|
|
||||||
|
|
||||||
// Стало:
|
|
||||||
configHandler := handlers.NewConfigurationHandler(localConfigService, exportService)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Шаг 3: Добавить endpoints для sync
|
|
||||||
|
|
||||||
В роутере добавить:
|
|
||||||
```go
|
|
||||||
syncGroup := router.Group("/api/sync")
|
|
||||||
{
|
|
||||||
syncGroup.POST("/push", syncHandler.PushPendingChanges)
|
|
||||||
syncGroup.GET("/pending/count", syncHandler.GetPendingCount)
|
|
||||||
syncGroup.GET("/pending", syncHandler.GetPendingChanges)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Как это работает
|
|
||||||
|
|
||||||
### Создание конфигурации
|
|
||||||
|
|
||||||
1. Пользователь создает конфигурацию
|
|
||||||
2. `LocalConfigurationService.Create()`:
|
|
||||||
- Если online → `SyncPricelistsIfNeeded()` проверяет новые прайслисты
|
|
||||||
- Сохраняет конфигурацию в SQLite
|
|
||||||
- Добавляет в `pending_changes` с operation="create"
|
|
||||||
3. Конфигурация доступна локально сразу
|
|
||||||
|
|
||||||
### Синхронизация с сервером
|
|
||||||
|
|
||||||
**Manual sync:**
|
|
||||||
```bash
|
|
||||||
POST /api/sync/push
|
|
||||||
```
|
|
||||||
|
|
||||||
**Background sync (TODO):**
|
|
||||||
- Периодический worker вызывает `syncService.PushPendingChanges()`
|
|
||||||
- Проверяет online статус
|
|
||||||
- Отправляет все pending changes на сервер
|
|
||||||
- Удаляет успешно синхронизированные записи
|
|
||||||
|
|
||||||
### Offline режим
|
|
||||||
|
|
||||||
1. Все операции работают нормально через SQLite
|
|
||||||
2. Изменения копятся в `pending_changes`
|
|
||||||
3. При восстановлении соединения автоматически синхронизируются
|
|
||||||
|
|
||||||
## Pending Changes Queue
|
|
||||||
|
|
||||||
Таблица `pending_changes`:
|
|
||||||
```go
|
|
||||||
type PendingChange struct {
|
|
||||||
ID int64 // Auto-increment
|
|
||||||
EntityType string // "configuration", "project", "specification"
|
|
||||||
EntityUUID string // UUID сущности
|
|
||||||
Operation string // "create", "update", "delete"
|
|
||||||
Payload string // JSON snapshot сущности
|
|
||||||
CreatedAt time.Time
|
|
||||||
Attempts int // Счетчик попыток синхронизации
|
|
||||||
LastError string // Последняя ошибка синхронизации
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## TODO для Phase 2.5
|
|
||||||
|
|
||||||
- [ ] Background sync worker (автоматическая синхронизация каждые N минут)
|
|
||||||
- [ ] Conflict resolution (при конфликтах обновления)
|
|
||||||
- [ ] UI: pending counter в header
|
|
||||||
- [ ] UI: manual sync button
|
|
||||||
- [ ] UI: conflict alerts
|
|
||||||
- [ ] Retry logic для failed pending changes
|
|
||||||
- [ ] RefreshPrices для local mode (через local_components)
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Compile
|
|
||||||
go build ./cmd/qfs
|
|
||||||
|
|
||||||
# Run
|
|
||||||
./quoteforge
|
|
||||||
|
|
||||||
# Check pending changes
|
|
||||||
curl http://localhost:8080/api/sync/pending/count
|
|
||||||
|
|
||||||
# Manual sync
|
|
||||||
curl -X POST http://localhost:8080/api/sync/push
|
|
||||||
```
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
# Миграция: Функционал пересчета цен в конфигураторе
|
|
||||||
|
|
||||||
## Описание изменений
|
|
||||||
|
|
||||||
Добавлен функционал автоматического обновления цен компонентов в сохраненных конфигурациях.
|
|
||||||
|
|
||||||
### Новые возможности
|
|
||||||
|
|
||||||
1. **Кнопка "Пересчитать цену"** на странице конфигуратора
|
|
||||||
- Обновляет цены всех компонентов в конфигурации до актуальных значений из базы данных
|
|
||||||
- Сохраняет количество компонентов, обновляя только цены
|
|
||||||
- Отображает время последнего обновления цен
|
|
||||||
|
|
||||||
2. **Поле `price_updated_at`** в таблице конфигураций
|
|
||||||
- Хранит дату и время последнего обновления цен
|
|
||||||
- Отображается на странице конфигуратора в удобном формате ("5 мин. назад", "2 ч. назад" и т.д.)
|
|
||||||
|
|
||||||
### Изменения в базе данных
|
|
||||||
|
|
||||||
Добавлено новое поле в таблицу `qt_configurations`:
|
|
||||||
```sql
|
|
||||||
ALTER TABLE qt_configurations
|
|
||||||
ADD COLUMN price_updated_at TIMESTAMP NULL DEFAULT NULL
|
|
||||||
AFTER server_count;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Новый API endpoint
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/configs/:uuid/refresh-prices
|
|
||||||
```
|
|
||||||
|
|
||||||
**Требования:**
|
|
||||||
- Авторизация: Bearer Token
|
|
||||||
- Роль: editor или выше
|
|
||||||
|
|
||||||
**Ответ:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"uuid": "...",
|
|
||||||
"name": "Конфигурация 1",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"lot_name": "CPU_AMD_9654",
|
|
||||||
"quantity": 2,
|
|
||||||
"unit_price": 11500.00
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"total_price": 23000.00,
|
|
||||||
"price_updated_at": "2026-01-31T12:34:56Z",
|
|
||||||
...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Применение изменений
|
|
||||||
|
|
||||||
### 1. Обновление базы данных
|
|
||||||
|
|
||||||
Запустите сервер с флагом миграции:
|
|
||||||
```bash
|
|
||||||
./quoteforge -migrate -config config.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
Или выполните SQL миграцию вручную:
|
|
||||||
```bash
|
|
||||||
mysql -u user -p RFQ_LOG < migrations/004_add_price_updated_at.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Перезапуск сервера
|
|
||||||
|
|
||||||
После применения миграции перезапустите сервер:
|
|
||||||
```bash
|
|
||||||
./quoteforge -config config.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
## Использование
|
|
||||||
|
|
||||||
1. Откройте любую сохраненную конфигурацию в конфигураторе
|
|
||||||
2. Нажмите кнопку **"Пересчитать цену"** рядом с кнопкой "Сохранить"
|
|
||||||
3. Все цены компонентов будут обновлены до актуальных значений
|
|
||||||
4. Конфигурация автоматически сохраняется с обновленными ценами
|
|
||||||
5. Под кнопками отображается время последнего обновления цен
|
|
||||||
|
|
||||||
## Технические детали
|
|
||||||
|
|
||||||
### Измененные файлы
|
|
||||||
|
|
||||||
- `internal/models/configuration.go` - добавлено поле `PriceUpdatedAt`
|
|
||||||
- `internal/services/configuration.go` - добавлен метод `RefreshPrices()`
|
|
||||||
- `internal/handlers/configuration.go` - добавлен обработчик `RefreshPrices()`
|
|
||||||
- `cmd/qfs/main.go` - добавлен маршрут `/api/configs/:uuid/refresh-prices`
|
|
||||||
- `web/templates/index.html` - добавлена кнопка и JavaScript функции
|
|
||||||
- `migrations/004_add_price_updated_at.sql` - SQL миграция
|
|
||||||
- `CLAUDE.md` - обновлена документация
|
|
||||||
|
|
||||||
### Логика обновления цен
|
|
||||||
|
|
||||||
1. Получение конфигурации по UUID
|
|
||||||
2. Проверка прав доступа (пользователь должен быть владельцем)
|
|
||||||
3. Для каждого компонента в конфигурации:
|
|
||||||
- Получение актуальной цены из `qt_lot_metadata.current_price`
|
|
||||||
- Обновление `unit_price` в items
|
|
||||||
4. Пересчет `total_price` с учетом `server_count`
|
|
||||||
5. Установка `price_updated_at` на текущее время
|
|
||||||
6. Сохранение конфигурации
|
|
||||||
|
|
||||||
### Обработка ошибок
|
|
||||||
|
|
||||||
- Если компонент не найден или у него нет цены - сохраняется старая цена
|
|
||||||
- При ошибках доступа возвращается 403 Forbidden
|
|
||||||
- При отсутствии конфигурации возвращается 404 Not Found
|
|
||||||
|
|
||||||
## Отмена изменений (Rollback)
|
|
||||||
|
|
||||||
Для отмены миграции выполните:
|
|
||||||
```sql
|
|
||||||
ALTER TABLE qt_configurations DROP COLUMN price_updated_at;
|
|
||||||
```
|
|
||||||
|
|
||||||
**Внимание:** После отмены миграции функционал пересчета цен перестанет работать корректно.
|
|
||||||
9
Makefile
9
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: build build-release clean test run version
|
.PHONY: build build-release clean test run version install-hooks
|
||||||
|
|
||||||
# Get version from git
|
# Get version from git
|
||||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||||
@@ -72,6 +72,12 @@ deps:
|
|||||||
go mod download
|
go mod download
|
||||||
go mod tidy
|
go mod tidy
|
||||||
|
|
||||||
|
# Install local git hooks
|
||||||
|
install-hooks:
|
||||||
|
git config core.hooksPath .githooks
|
||||||
|
chmod +x .githooks/pre-commit scripts/check-secrets.sh
|
||||||
|
@echo "Installed git hooks from .githooks/"
|
||||||
|
|
||||||
# Help
|
# Help
|
||||||
help:
|
help:
|
||||||
@echo "QuoteForge Server (qfs) - Build Commands"
|
@echo "QuoteForge Server (qfs) - Build Commands"
|
||||||
@@ -92,6 +98,7 @@ help:
|
|||||||
@echo " run Run development server"
|
@echo " run Run development server"
|
||||||
@echo " watch Run with auto-restart (requires entr)"
|
@echo " watch Run with auto-restart (requires entr)"
|
||||||
@echo " deps Install/update dependencies"
|
@echo " deps Install/update dependencies"
|
||||||
|
@echo " install-hooks Install local git hooks (secret scan on commit)"
|
||||||
@echo " help Show this help"
|
@echo " help Show this help"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Current version: $(VERSION)"
|
@echo "Current version: $(VERSION)"
|
||||||
|
|||||||
252
README.md
252
README.md
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
**Server Configuration & Quotation Tool**
|
**Server Configuration & Quotation Tool**
|
||||||
|
|
||||||
QuoteForge — корпоративный инструмент для конфигурирования серверов и формирования коммерческих предложений (КП). Приложение интегрируется с существующей базой данных RFQ_LOG.
|
QuoteForge — корпоративный инструмент для конфигурирования серверов и формирования коммерческих предложений (КП).
|
||||||
|
Приложение работает в strict local-first режиме: пользовательские операции выполняются через локальную SQLite, MariaDB используется для синхронизации и серверного администрирования прайслистов.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
@@ -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
|
||||||
|
|
||||||
## Требования
|
## Требования
|
||||||
@@ -53,13 +56,13 @@ git clone https://github.com/your-company/quoteforge.git
|
|||||||
cd quoteforge
|
cd quoteforge
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Настройка конфигурации
|
### 2. Настройка runtime-конфига (опционально)
|
||||||
|
|
||||||
```bash
|
`config.yaml` создаётся автоматически при первом старте в той же user-state папке, где находится `qfs.db`.
|
||||||
cp config.example.yaml config.yaml
|
Если найден старый формат, приложение автоматически мигрирует файл в актуальный runtime-формат
|
||||||
```
|
(оставляя только используемые секции `server` и `logging`).
|
||||||
|
|
||||||
Отредактируйте `config.yaml`:
|
При необходимости можно создать/отредактировать файл вручную:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
server:
|
server:
|
||||||
@@ -67,16 +70,10 @@ server:
|
|||||||
port: 8080
|
port: 8080
|
||||||
mode: "release"
|
mode: "release"
|
||||||
|
|
||||||
database:
|
logging:
|
||||||
host: "localhost"
|
level: "info"
|
||||||
port: 3306
|
format: "json"
|
||||||
name: "RFQ_LOG"
|
output: "stdout"
|
||||||
user: "quoteforge"
|
|
||||||
password: "your-secure-password"
|
|
||||||
|
|
||||||
auth:
|
|
||||||
jwt_secret: "your-jwt-secret-min-32-chars"
|
|
||||||
token_expiry: "24h"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Миграции базы данных
|
### 3. Миграции базы данных
|
||||||
@@ -85,6 +82,109 @@ 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
|
||||||
|
```
|
||||||
|
|
||||||
|
Применение изменений:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/migrate_ops_projects -apply
|
||||||
|
```
|
||||||
|
|
||||||
|
Без интерактивного подтверждения:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/migrate_ops_projects -apply -yes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Права БД для пользователя приложения
|
||||||
|
|
||||||
|
#### Полный набор прав для обычного пользователя
|
||||||
|
|
||||||
|
Чтобы выдать существующему пользователю все необходимые права (без переоздания):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Справочные таблицы (только чтение)
|
||||||
|
GRANT SELECT ON RFQ_LOG.lot TO '<DB_USER>'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO '<DB_USER>'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_categories TO '<DB_USER>'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_pricelists TO '<DB_USER>'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO '<DB_USER>'@'%';
|
||||||
|
|
||||||
|
-- Таблицы конфигураций и проектов (чтение и запись)
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO '<DB_USER>'@'%';
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO '<DB_USER>'@'%';
|
||||||
|
|
||||||
|
-- Таблицы синхронизации (только чтение для миграций, чтение+запись для статуса)
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_client_local_migrations TO '<DB_USER>'@'%';
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO '<DB_USER>'@'%';
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO '<DB_USER>'@'%';
|
||||||
|
|
||||||
|
-- Применить изменения
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
|
||||||
|
-- Проверка выданных прав
|
||||||
|
SHOW GRANTS FOR '<DB_USER>'@'%';
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Таблицы и их назначение
|
||||||
|
|
||||||
|
| Таблица | Назначение | Права | Примечание |
|
||||||
|
|---------|-----------|-------|-----------|
|
||||||
|
| `lot` | Справочник компонентов | SELECT | Существующая таблица |
|
||||||
|
| `qt_lot_metadata` | Расширенные данные компонентов | SELECT | Метаданные компонентов |
|
||||||
|
| `qt_categories` | Категории компонентов | SELECT | Справочник |
|
||||||
|
| `qt_pricelists` | Прайслисты | SELECT | Управляется сервером |
|
||||||
|
| `qt_pricelist_items` | Позиции прайслистов | SELECT | Управляется сервером |
|
||||||
|
| `qt_configurations` | Сохранённые конфигурации | SELECT, INSERT, UPDATE | Основная таблица работы |
|
||||||
|
| `qt_projects` | Проекты | SELECT, INSERT, UPDATE | Для группировки конфигураций |
|
||||||
|
| `qt_client_local_migrations` | Справочник миграций БД | SELECT | Только чтение (управляется админом) |
|
||||||
|
| `qt_client_schema_state` | Состояние локальной схемы | SELECT, INSERT, UPDATE | Отслеживание примененных миграций |
|
||||||
|
| `qt_pricelist_sync_status` | Статус синхронизации | SELECT, INSERT, UPDATE | Отслеживание активности синхронизации |
|
||||||
|
|
||||||
|
#### При создании нового пользователя
|
||||||
|
|
||||||
|
Если нужно создать нового пользователя с нуля:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 1) Создать пользователя
|
||||||
|
CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY '<DB_PASSWORD>';
|
||||||
|
|
||||||
|
-- 2) Выдать все необходимые права
|
||||||
|
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'@'%';
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO 'quote_user'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_client_local_migrations TO 'quote_user'@'%';
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO 'quote_user'@'%';
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO 'quote_user'@'%';
|
||||||
|
|
||||||
|
-- 3) Применить изменения
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
|
||||||
|
-- 4) Проверить права
|
||||||
|
SHOW GRANTS FOR 'quote_user'@'%';
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Важные замечания
|
||||||
|
|
||||||
|
- **Таблицы синхронизации** должны быть созданы администратором БД один раз. Приложение не требует прав CREATE TABLE.
|
||||||
|
- **Прайслисты** (`qt_pricelists`, `qt_pricelist_items`) — справочные таблицы, управляются сервером, пользователь имеет только SELECT.
|
||||||
|
- **Конфигурации и проекты** — таблицы, в которые пишет само приложение (INSERT, UPDATE при сохранении изменений).
|
||||||
|
- **Таблицы миграций** нужны для синхронизации: приложение читает список миграций и отчитывается о применённых.
|
||||||
|
- Если видите ошибку `Access denied for user ...@'<ip>'`, проверьте наличие конфликтующих записей пользователя с разными хостами (user@localhost vs user@'%').
|
||||||
|
|
||||||
### 4. Импорт метаданных компонентов
|
### 4. Импорт метаданных компонентов
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -114,6 +214,7 @@ make build-all # Сборка для всех платформ (Linux, mac
|
|||||||
make build-windows # Только для Windows
|
make build-windows # Только для Windows
|
||||||
make run # Запуск dev сервера
|
make run # Запуск dev сервера
|
||||||
make test # Запуск тестов
|
make test # Запуск тестов
|
||||||
|
make install-hooks # Установить git hooks (блокировка коммита с секретами)
|
||||||
make clean # Очистка bin/
|
make clean # Очистка bin/
|
||||||
make help # Показать все команды
|
make help # Показать все команды
|
||||||
```
|
```
|
||||||
@@ -131,6 +232,56 @@ 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 показывается красный индикатор и причина блокировки в модалке синхронизации.
|
||||||
|
|
||||||
|
#### Схема потоков данных синхронизации
|
||||||
|
|
||||||
|
```text
|
||||||
|
[ SERVER / MariaDB ]
|
||||||
|
┌───────────────────────────┐
|
||||||
|
│ qt_projects │
|
||||||
|
│ qt_configurations │
|
||||||
|
│ qt_pricelists │
|
||||||
|
│ qt_pricelist_items │
|
||||||
|
│ qt_pricelist_sync_status │
|
||||||
|
└─────────────┬─────────────┘
|
||||||
|
│
|
||||||
|
pull (projects/configs/pricelists)
|
||||||
|
│
|
||||||
|
┌──────────────────┴──────────────────┐
|
||||||
|
│ │
|
||||||
|
[ CLIENT A / local SQLite ] [ CLIENT B / local SQLite ]
|
||||||
|
┌───────────────────────────────┐ ┌───────────────────────────────┐
|
||||||
|
│ local_projects │ │ local_projects │
|
||||||
|
│ local_configurations │ │ local_configurations │
|
||||||
|
│ local_pricelists │ │ local_pricelists │
|
||||||
|
│ local_pricelist_items │ │ local_pricelist_items │
|
||||||
|
│ pending_changes (proj/config) │ │ pending_changes (proj/config) │
|
||||||
|
└───────────────┬───────────────┘ └───────────────┬───────────────┘
|
||||||
|
│ │
|
||||||
|
push (projects/configurations only) push (projects/configurations only)
|
||||||
|
│ │
|
||||||
|
└──────────────────┬────────────────────┘
|
||||||
|
│
|
||||||
|
[ SERVER / MariaDB ]
|
||||||
|
```
|
||||||
|
|
||||||
|
По сущностям:
|
||||||
|
- Конфигурации: `Client <-> Server <-> Other Clients`
|
||||||
|
- Проекты: `Client <-> Server <-> Other Clients`
|
||||||
|
- Прайслисты: `Server -> Clients only` (локальный push отсутствует)
|
||||||
|
- Локальная очистка прайслистов на клиенте: удаляются записи, которых нет на сервере и которые не используются активными локальными конфигурациями
|
||||||
|
|
||||||
### Версионность конфигураций (local-first)
|
### Версионность конфигураций (local-first)
|
||||||
|
|
||||||
Для `local_configurations` используется append-only versioning через полные snapshot-версии:
|
Для `local_configurations` используется append-only versioning через полные snapshot-версии:
|
||||||
@@ -164,6 +315,7 @@ POST /api/configs/:uuid/rollback
|
|||||||
### Локальный config.yaml
|
### Локальный config.yaml
|
||||||
|
|
||||||
По умолчанию `qfs` ищет `config.yaml` в той же user-state папке, где лежит `qfs.db` (а не рядом с бинарником).
|
По умолчанию `qfs` ищет `config.yaml` в той же user-state папке, где лежит `qfs.db` (а не рядом с бинарником).
|
||||||
|
Если файла нет, он создаётся автоматически. Если формат устарел, он автоматически мигрируется в runtime-формат (`server` + `logging`).
|
||||||
Можно переопределить путь через `-config` или `QFS_CONFIG_PATH`.
|
Можно переопределить путь через `-config` или `QFS_CONFIG_PATH`.
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
@@ -194,9 +346,7 @@ quoteforge/
|
|||||||
│ ├── templates/ # HTML шаблоны
|
│ ├── templates/ # HTML шаблоны
|
||||||
│ └── static/ # CSS, JS, изображения
|
│ └── static/ # CSS, JS, изображения
|
||||||
├── migrations/ # SQL миграции
|
├── migrations/ # SQL миграции
|
||||||
├── config.yaml # Конфигурация
|
├── config.example.yaml # Пример конфигурации
|
||||||
├── Dockerfile
|
|
||||||
├── docker-compose.yml
|
|
||||||
└── go.mod
|
└── go.mod
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -225,8 +375,26 @@ 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 API
|
||||||
|
|
||||||
|
| Endpoint | Назначение | Поток |
|
||||||
|
|----------|------------|-------|
|
||||||
|
| `POST /api/sync/push` | Отправить локальные pending-изменения | `SQLite -> MariaDB` |
|
||||||
|
| `POST /api/sync/components` | Подтянуть справочник компонентов | `MariaDB -> SQLite` |
|
||||||
|
| `POST /api/sync/pricelists` | Подтянуть прайслисты и позиции | `MariaDB -> SQLite` |
|
||||||
|
| `POST /api/sync/all` | Полный цикл: push + pull + импорт проектов/конфигураций | `двунаправленно` |
|
||||||
|
| `GET /api/sync/readiness` | Статус preflight/readiness | `read-only` |
|
||||||
|
| `GET /api/sync/status` / `GET /api/sync/info` | Сводка статуса и данных синхронизации | `read-only` |
|
||||||
|
|
||||||
#### Sync payload для versioning
|
#### Sync payload для versioning
|
||||||
|
|
||||||
События в `pending_changes` для конфигураций содержат:
|
События в `pending_changes` для конфигураций содержат:
|
||||||
@@ -238,50 +406,6 @@ POST /api/configs/:uuid/reactivate # Вернуть архивную к
|
|||||||
|
|
||||||
Это позволяет push-слою отправлять на сервер актуальное состояние и готовит основу для будущего conflict resolution.
|
Это позволяет push-слою отправлять на сервер актуальное состояние и готовит основу для будущего conflict resolution.
|
||||||
|
|
||||||
## Cron Jobs
|
|
||||||
|
|
||||||
QuoteForge now includes automated cron jobs for maintenance tasks. These can be run using the built-in cron functionality in the Docker container.
|
|
||||||
|
|
||||||
### Docker Compose Setup
|
|
||||||
|
|
||||||
The Docker setup includes a dedicated cron service that runs the following jobs:
|
|
||||||
|
|
||||||
- **Alerts check**: Every hour (0 * * * *)
|
|
||||||
- **Price updates**: Daily at 2 AM (0 2 * * *)
|
|
||||||
- **Usage counter reset**: Weekly on Sunday at 1 AM (0 1 * * 0)
|
|
||||||
- **Popularity score updates**: Daily at 3 AM (0 3 * * *)
|
|
||||||
|
|
||||||
To enable cron jobs in Docker, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### Manual Cron Job Execution
|
|
||||||
|
|
||||||
You can also run cron jobs manually using the quoteforge-cron binary:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check and generate alerts
|
|
||||||
go run ./cmd/cron -job=alerts
|
|
||||||
|
|
||||||
# Recalculate all prices
|
|
||||||
go run ./cmd/cron -job=update-prices
|
|
||||||
|
|
||||||
# Reset usage counters
|
|
||||||
go run ./cmd/cron -job=reset-counters
|
|
||||||
|
|
||||||
# Update popularity scores
|
|
||||||
go run ./cmd/cron -job=update-popularity
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cron Job Details
|
|
||||||
|
|
||||||
- **Alerts check**: Generates alerts for components with high demand and stale prices, trending components without prices, and components with no recent quotes
|
|
||||||
- **Price updates**: Recalculates prices for all components using configured methods (median, weighted median, average)
|
|
||||||
- **Usage counter reset**: Resets weekly and monthly usage counters for components
|
|
||||||
- **Popularity score updates**: Recalculates popularity scores based on supplier quote activity
|
|
||||||
|
|
||||||
## Разработка
|
## Разработка
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"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/localdb"
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"gorm.io/driver/mysql"
|
"gorm.io/driver/mysql"
|
||||||
@@ -16,7 +15,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
configPath := flag.String("config", "config.yaml", "path to config file")
|
|
||||||
defaultLocalDBPath, err := appstate.ResolveDBPath("")
|
defaultLocalDBPath, err := appstate.ResolveDBPath("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to resolve default local SQLite path: %v", err)
|
log.Fatalf("Failed to resolve default local SQLite path: %v", err)
|
||||||
@@ -28,22 +26,6 @@ func main() {
|
|||||||
log.Println("QuoteForge Configuration Migration Tool")
|
log.Println("QuoteForge Configuration Migration Tool")
|
||||||
log.Println("========================================")
|
log.Println("========================================")
|
||||||
|
|
||||||
// Load config for MariaDB connection
|
|
||||||
cfg, err := config.Load(*configPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to load config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect to MariaDB
|
|
||||||
log.Printf("Connecting to MariaDB at %s:%d...", cfg.Database.Host, cfg.Database.Port)
|
|
||||||
mariaDB, 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 MariaDB: %v", err)
|
|
||||||
}
|
|
||||||
log.Println("Connected to MariaDB")
|
|
||||||
|
|
||||||
// Initialize local SQLite
|
// Initialize local SQLite
|
||||||
log.Printf("Opening local SQLite at %s...", *localDBPath)
|
log.Printf("Opening local SQLite at %s...", *localDBPath)
|
||||||
local, err := localdb.New(*localDBPath)
|
local, err := localdb.New(*localDBPath)
|
||||||
@@ -51,6 +33,28 @@ func main() {
|
|||||||
log.Fatalf("Failed to initialize local database: %v", err)
|
log.Fatalf("Failed to initialize local database: %v", err)
|
||||||
}
|
}
|
||||||
log.Println("Local SQLite initialized")
|
log.Println("Local SQLite initialized")
|
||||||
|
if !local.HasSettings() {
|
||||||
|
log.Fatalf("SQLite connection settings are not configured. Run qfs setup first.")
|
||||||
|
}
|
||||||
|
|
||||||
|
settings, err := local.GetSettings()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load SQLite connection settings: %v", err)
|
||||||
|
}
|
||||||
|
dsn, err := local.GetDSN()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to build DSN from SQLite settings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to MariaDB
|
||||||
|
log.Printf("Connecting to MariaDB at %s:%d...", settings.Host, settings.Port)
|
||||||
|
mariaDB, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to connect to MariaDB: %v", err)
|
||||||
|
}
|
||||||
|
log.Println("Connected to MariaDB")
|
||||||
|
|
||||||
// Count configurations in MariaDB
|
// Count configurations in MariaDB
|
||||||
var serverCount int64
|
var serverCount int64
|
||||||
@@ -66,7 +70,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 +82,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 +121,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 +133,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++
|
||||||
@@ -155,21 +153,12 @@ func main() {
|
|||||||
log.Printf(" Skipped: %d", skipped)
|
log.Printf(" Skipped: %d", skipped)
|
||||||
log.Printf(" Errors: %d", errors)
|
log.Printf(" Errors: %d", errors)
|
||||||
|
|
||||||
// Save connection settings to local SQLite if not exists
|
fmt.Println("\nDone! You can now run the server with: go run ./cmd/qfs")
|
||||||
if !local.HasSettings() {
|
}
|
||||||
log.Println("\nSaving connection settings to local SQLite...")
|
|
||||||
if err := local.SaveSettings(
|
func derefUint(v *uint) uint {
|
||||||
cfg.Database.Host,
|
if v == nil {
|
||||||
cfg.Database.Port,
|
return 0
|
||||||
cfg.Database.Name,
|
}
|
||||||
cfg.Database.User,
|
return *v
|
||||||
cfg.Database.Password,
|
|
||||||
); err != nil {
|
|
||||||
log.Printf("Warning: Failed to save settings: %v", err)
|
|
||||||
} else {
|
|
||||||
log.Println("Connection settings saved")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("\nDone! You can now run the server with: go run ./cmd/server")
|
|
||||||
}
|
}
|
||||||
|
|||||||
296
cmd/migrate_ops_projects/main.go
Normal file
296
cmd/migrate_ops_projects/main.go
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/appstate"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
"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() {
|
||||||
|
defaultLocalDBPath, err := appstate.ResolveDBPath("")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to resolve default local SQLite path: %v", err)
|
||||||
|
}
|
||||||
|
localDBPath := flag.String("localdb", defaultLocalDBPath, "path to local SQLite database (default: user state dir or QFS_DB_PATH)")
|
||||||
|
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()
|
||||||
|
|
||||||
|
local, err := localdb.New(*localDBPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to initialize local database: %v", err)
|
||||||
|
}
|
||||||
|
if !local.HasSettings() {
|
||||||
|
log.Fatalf("SQLite connection settings are not configured. Run qfs setup first.")
|
||||||
|
}
|
||||||
|
dsn, err := local.GetDSN()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to build DSN from SQLite settings: %v", err)
|
||||||
|
}
|
||||||
|
dbUser := strings.TrimSpace(local.GetDBUser())
|
||||||
|
|
||||||
|
db, err := gorm.Open(mysql.Open(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, dbUser)
|
||||||
|
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
|
||||||
|
}
|
||||||
66
cmd/qfs/config_migration_test.go
Normal file
66
cmd/qfs/config_migration_test.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMigrateConfigFileToRuntimeShapeDropsDeprecatedSections(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
|
||||||
|
legacy := `server:
|
||||||
|
host: "0.0.0.0"
|
||||||
|
port: 9191
|
||||||
|
database:
|
||||||
|
host: "legacy-db"
|
||||||
|
port: 3306
|
||||||
|
name: "RFQ_LOG"
|
||||||
|
user: "old"
|
||||||
|
password: "REDACTED_TEST_PASSWORD"
|
||||||
|
pricing:
|
||||||
|
default_method: "median"
|
||||||
|
logging:
|
||||||
|
level: "debug"
|
||||||
|
format: "text"
|
||||||
|
output: "stdout"
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(path, []byte(legacy), 0644); err != nil {
|
||||||
|
t.Fatalf("write legacy config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load legacy config: %v", err)
|
||||||
|
}
|
||||||
|
setConfigDefaults(cfg)
|
||||||
|
if err := migrateConfigFileToRuntimeShape(path, cfg); err != nil {
|
||||||
|
t.Fatalf("migrate config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read migrated config: %v", err)
|
||||||
|
}
|
||||||
|
text := string(got)
|
||||||
|
if strings.Contains(text, "database:") {
|
||||||
|
t.Fatalf("migrated config still contains deprecated database section:\n%s", text)
|
||||||
|
}
|
||||||
|
if strings.Contains(text, "pricing:") {
|
||||||
|
t.Fatalf("migrated config still contains deprecated pricing section:\n%s", text)
|
||||||
|
}
|
||||||
|
if !strings.Contains(text, "server:") || !strings.Contains(text, "logging:") {
|
||||||
|
t.Fatalf("migrated config missing required sections:\n%s", text)
|
||||||
|
}
|
||||||
|
if !strings.Contains(text, "port: 9191") {
|
||||||
|
t.Fatalf("migrated config did not preserve server port:\n%s", text)
|
||||||
|
}
|
||||||
|
if !strings.Contains(text, "level: debug") {
|
||||||
|
t.Fatalf("migrated config did not preserve logging level:\n%s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
738
cmd/qfs/main.go
738
cmd/qfs/main.go
@@ -1,23 +1,29 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"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,11 +33,9 @@ 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"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
"gorm.io/driver/mysql"
|
"gorm.io/driver/mysql"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
@@ -40,6 +44,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,12 +62,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)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to resolve config path", "error", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
resolvedLocalDBPath, err := appstate.ResolveDBPath(*localDBPath)
|
resolvedLocalDBPath, err := appstate.ResolveDBPath(*localDBPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -68,6 +70,12 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resolvedConfigPath, err := appstate.ResolveConfigPathNearDB(*configPath, resolvedLocalDBPath)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to resolve config path", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
// Migrate legacy project-local config path to the user state directory when using defaults.
|
// Migrate legacy project-local config path to the user state directory when using defaults.
|
||||||
if *configPath == "" && os.Getenv("QFS_CONFIG_PATH") == "" {
|
if *configPath == "" && os.Getenv("QFS_CONFIG_PATH") == "" {
|
||||||
migratedFrom, migrateErr := appstate.MigrateLegacyFile(resolvedConfigPath, []string{"config.yaml"})
|
migratedFrom, migrateErr := appstate.MigrateLegacyFile(resolvedConfigPath, []string{"config.yaml"})
|
||||||
@@ -107,6 +115,10 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load config for server settings (optional)
|
// Load config for server settings (optional)
|
||||||
|
if err := ensureDefaultConfigFile(resolvedConfigPath); err != nil {
|
||||||
|
slog.Error("failed to ensure default config file", "path", resolvedConfigPath, "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
cfg, err := config.Load(resolvedConfigPath)
|
cfg, err := config.Load(resolvedConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
@@ -119,6 +131,10 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setConfigDefaults(cfg)
|
setConfigDefaults(cfg)
|
||||||
|
if err := migrateConfigFileToRuntimeShape(resolvedConfigPath, cfg); err != nil {
|
||||||
|
slog.Error("failed to migrate config file format", "path", resolvedConfigPath, "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
slog.Info("resolved runtime files", "config_path", resolvedConfigPath, "localdb_path", resolvedLocalDBPath)
|
slog.Info("resolved runtime files", "config_path", resolvedConfigPath, "localdb_path", resolvedLocalDBPath)
|
||||||
|
|
||||||
setupLogger(cfg.Logging)
|
setupLogger(cfg.Logging)
|
||||||
@@ -162,18 +178,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 +261,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 +284,10 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("server stopped")
|
slog.Info("server stopped")
|
||||||
|
|
||||||
|
if shouldRestart {
|
||||||
|
restartProcess()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setConfigDefaults(cfg *config.Config) {
|
func setConfigDefaults(cfg *config.Config) {
|
||||||
@@ -260,6 +326,96 @@ func setConfigDefaults(cfg *config.Config) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureDefaultConfigFile(configPath string) error {
|
||||||
|
if strings.TrimSpace(configPath) == "" {
|
||||||
|
return fmt.Errorf("config path is empty")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(configPath); err == nil {
|
||||||
|
return nil
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultConfigYAML = `server:
|
||||||
|
host: "127.0.0.1"
|
||||||
|
port: 8080
|
||||||
|
mode: "release"
|
||||||
|
read_timeout: 30s
|
||||||
|
write_timeout: 30s
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level: "info"
|
||||||
|
format: "json"
|
||||||
|
output: "stdout"
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(configPath, []byte(defaultConfigYAML), 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
slog.Info("created default config file", "path", configPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type runtimeServerConfig struct {
|
||||||
|
Host string `yaml:"host"`
|
||||||
|
Port int `yaml:"port"`
|
||||||
|
Mode string `yaml:"mode"`
|
||||||
|
ReadTimeout time.Duration `yaml:"read_timeout"`
|
||||||
|
WriteTimeout time.Duration `yaml:"write_timeout"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type runtimeLoggingConfig struct {
|
||||||
|
Level string `yaml:"level"`
|
||||||
|
Format string `yaml:"format"`
|
||||||
|
Output string `yaml:"output"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type runtimeConfigFile struct {
|
||||||
|
Server runtimeServerConfig `yaml:"server"`
|
||||||
|
Logging runtimeLoggingConfig `yaml:"logging"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrateConfigFileToRuntimeShape rewrites config.yaml in a minimal runtime format.
|
||||||
|
// Deprecated sections from legacy configs are intentionally dropped.
|
||||||
|
func migrateConfigFileToRuntimeShape(configPath string, cfg *config.Config) error {
|
||||||
|
if cfg == nil {
|
||||||
|
return fmt.Errorf("config is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
runtimeCfg := runtimeConfigFile{
|
||||||
|
Server: runtimeServerConfig{
|
||||||
|
Host: cfg.Server.Host,
|
||||||
|
Port: cfg.Server.Port,
|
||||||
|
Mode: cfg.Server.Mode,
|
||||||
|
ReadTimeout: cfg.Server.ReadTimeout,
|
||||||
|
WriteTimeout: cfg.Server.WriteTimeout,
|
||||||
|
},
|
||||||
|
Logging: runtimeLoggingConfig{
|
||||||
|
Level: cfg.Logging.Level,
|
||||||
|
Format: cfg.Logging.Format,
|
||||||
|
Output: cfg.Logging.Output,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered, err := yaml.Marshal(&runtimeCfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal runtime config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
current, err := os.ReadFile(configPath)
|
||||||
|
if err == nil && bytes.Equal(bytes.TrimSpace(current), bytes.TrimSpace(rendered)) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(configPath, rendered, 0644); err != nil {
|
||||||
|
return fmt.Errorf("write runtime config: %w", err)
|
||||||
|
}
|
||||||
|
slog.Info("migrated config.yaml to runtime format", "path", configPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// runSetupMode starts a minimal server that only serves the setup page
|
// runSetupMode starts a minimal server that only serves the setup page
|
||||||
func runSetupMode(local *localdb.LocalDB) {
|
func runSetupMode(local *localdb.LocalDB) {
|
||||||
restartSig := make(chan struct{}, 1)
|
restartSig := make(chan struct{}, 1)
|
||||||
@@ -392,14 +548,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 +561,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 +569,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 +595,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 +696,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 +752,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 +813,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 +847,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 +860,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 +893,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 +1023,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")
|
||||||
|
|
||||||
@@ -879,29 +1152,356 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
"current_version": currentVersion,
|
"current_version": currentVersion,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
configs.GET("/:uuid/export", exportHandler.ExportConfigCSV)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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"))
|
||||||
|
// Return all projects by default (set high limit for configs to reference)
|
||||||
|
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "1000"))
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
// GET /api/projects/all - Returns all projects without pagination for UI dropdowns
|
||||||
|
projects.GET("/all", func(c *gin.Context) {
|
||||||
|
allProjects, err := projectService.ListByUser(dbUsername, true)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return simplified list of all projects (UUID + Name only)
|
||||||
|
type ProjectSimple struct {
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
simplified := make([]ProjectSimple, 0, len(allProjects))
|
||||||
|
for _, p := range allProjects {
|
||||||
|
simplified = append(simplified, ProjectSimple{
|
||||||
|
UUID: p.UUID,
|
||||||
|
Name: p.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, simplified)
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
15
crontab
15
crontab
@@ -1,15 +0,0 @@
|
|||||||
# Cron jobs for QuoteForge
|
|
||||||
# Run alerts check every hour
|
|
||||||
0 * * * * /app/quoteforge-cron -job=alerts
|
|
||||||
|
|
||||||
# Run price updates daily at 2 AM
|
|
||||||
0 2 * * * /app/quoteforge-cron -job=update-prices
|
|
||||||
|
|
||||||
# Reset weekly counters every Sunday at 1 AM
|
|
||||||
0 1 * * 0 /app/quoteforge-cron -job=reset-counters
|
|
||||||
|
|
||||||
# Update popularity scores daily at 3 AM
|
|
||||||
0 3 * * * /app/quoteforge-cron -job=update-popularity
|
|
||||||
|
|
||||||
# Log rotation (optional)
|
|
||||||
# 0 0 * * * /usr/bin/logrotate /etc/logrotate.conf
|
|
||||||
297
csv_export.md
Normal file
297
csv_export.md
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
# CSV Export Pattern (Go + GORM)
|
||||||
|
|
||||||
|
## Архитектура (3-слойная)
|
||||||
|
|
||||||
|
### 1. Handler Layer (HTTP)
|
||||||
|
**Задачи**: Обработка HTTP-запроса, установка заголовков, инициация экспорта
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (h *PricelistHandler) ExportCSV(c *gin.Context) {
|
||||||
|
// 1. Валидация параметров
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
|
||||||
|
// 2. Получение метаданных для формирования имени файла
|
||||||
|
pl, err := h.service.GetByID(uint(id))
|
||||||
|
|
||||||
|
// 3. Установка HTTP-заголовков для скачивания
|
||||||
|
filename := fmt.Sprintf("pricelist_%s.csv", pl.Version)
|
||||||
|
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||||
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||||
|
|
||||||
|
// 4. UTF-8 BOM для Excel-совместимости
|
||||||
|
c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})
|
||||||
|
|
||||||
|
// 5. Настройка CSV writer
|
||||||
|
writer := csv.NewWriter(c.Writer)
|
||||||
|
writer.Comma = ';' // Точка с запятой для Excel
|
||||||
|
defer writer.Flush()
|
||||||
|
|
||||||
|
// 6. Динамические заголовки (зависят от типа данных)
|
||||||
|
isWarehouse := strings.ToLower(pl.Source) == "warehouse"
|
||||||
|
var header []string
|
||||||
|
if isWarehouse {
|
||||||
|
header = []string{"Артикул", "Категория", "Описание", "Доступно", "Partnumbers", "Цена, $", "Настройки"}
|
||||||
|
} else {
|
||||||
|
header = []string{"Артикул", "Категория", "Описание", "Цена, $", "Настройки"}
|
||||||
|
}
|
||||||
|
writer.Write(header)
|
||||||
|
|
||||||
|
// 7. Streaming в batches через callback
|
||||||
|
err = h.service.StreamItemsForExport(uint(id), 500, func(items []models.PricelistItem) error {
|
||||||
|
for _, item := range items {
|
||||||
|
row := buildRow(item, isWarehouse)
|
||||||
|
if err := writer.Write(row); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writer.Flush() // Flush после каждого batch
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Service Layer
|
||||||
|
**Задачи**: Оркестрация, делегирование в репозиторий
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *Service) StreamItemsForExport(pricelistID uint, batchSize int, callback func(items []models.PricelistItem) error) error {
|
||||||
|
if s.repo == nil {
|
||||||
|
return fmt.Errorf("offline mode: cannot stream pricelist items")
|
||||||
|
}
|
||||||
|
return s.repo.StreamItemsForExport(pricelistID, batchSize, callback)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Repository Layer (Критичный)
|
||||||
|
**Задачи**: Batch-загрузка из БД, оптимизация запросов, enrichment
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (r *PricelistRepository) StreamItemsForExport(pricelistID uint, batchSize int, callback func(items []models.PricelistItem) error) error {
|
||||||
|
if batchSize <= 0 {
|
||||||
|
batchSize = 500 // Default batch size
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка типа pricelist для conditional enrichment
|
||||||
|
var pl models.Pricelist
|
||||||
|
isWarehouse := false
|
||||||
|
if err := r.db.Select("source").Where("id = ?", pricelistID).First(&pl).Error; err == nil {
|
||||||
|
isWarehouse = pl.Source == string(models.PricelistSourceWarehouse)
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := 0
|
||||||
|
for {
|
||||||
|
var items []models.PricelistItem
|
||||||
|
|
||||||
|
// ⚡ КЛЮЧЕВОЙ МОМЕНТ: JOIN для избежания N+1 запросов
|
||||||
|
err := r.db.Table("qt_pricelist_items AS pi").
|
||||||
|
Select("pi.*, COALESCE(l.lot_description, '') AS lot_description, COALESCE(l.lot_category, '') AS category").
|
||||||
|
Joins("LEFT JOIN lot AS l ON l.lot_name = pi.lot_name").
|
||||||
|
Where("pi.pricelist_id = ?", pricelistID).
|
||||||
|
Order("pi.lot_name").
|
||||||
|
Offset(offset).
|
||||||
|
Limit(batchSize).
|
||||||
|
Scan(&items).Error
|
||||||
|
|
||||||
|
if err != nil || len(items) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conditional enrichment для warehouse данных
|
||||||
|
if isWarehouse {
|
||||||
|
r.enrichWarehouseItems(items) // Добавление qty, partnumbers
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вызов callback для обработки batch
|
||||||
|
if err := callback(items); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(items) < batchSize {
|
||||||
|
break // Последний batch
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += batchSize
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ключевые паттерны
|
||||||
|
|
||||||
|
### 1. Streaming (не загружать все в память)
|
||||||
|
```go
|
||||||
|
// ❌ НЕ ТАК:
|
||||||
|
var allItems []Item
|
||||||
|
db.Find(&allItems) // Может упасть на миллионах записей
|
||||||
|
|
||||||
|
// ✅ ТАК:
|
||||||
|
for offset := 0; ; offset += batchSize {
|
||||||
|
var batch []Item
|
||||||
|
db.Offset(offset).Limit(batchSize).Find(&batch)
|
||||||
|
processBatch(batch)
|
||||||
|
if len(batch) < batchSize {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Callback Pattern для гибкости
|
||||||
|
```go
|
||||||
|
// Service не знает о CSV - может использоваться для любого экспорта
|
||||||
|
func StreamItems(callback func([]Item) error) error
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. JOIN для избежания N+1
|
||||||
|
```go
|
||||||
|
// ❌ N+1 problem:
|
||||||
|
items := getItems()
|
||||||
|
for _, item := range items {
|
||||||
|
description := getLotDescription(item.LotName) // N запросов
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ JOIN:
|
||||||
|
db.Table("items AS i").
|
||||||
|
Select("i.*, COALESCE(l.description, '') AS description").
|
||||||
|
Joins("LEFT JOIN lots AS l ON l.name = i.lot_name")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. UTF-8 BOM для Excel
|
||||||
|
```go
|
||||||
|
// Excel на Windows требует BOM для корректного отображения UTF-8
|
||||||
|
c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Точка с запятой для Excel
|
||||||
|
```go
|
||||||
|
writer := csv.NewWriter(c.Writer)
|
||||||
|
writer.Comma = ';' // Excel в русской локали использует ;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Graceful Error Handling
|
||||||
|
```go
|
||||||
|
// После начала streaming нельзя вернуть JSON
|
||||||
|
if err != nil {
|
||||||
|
// Уже начали писать CSV, поэтому пишем текст
|
||||||
|
c.String(http.StatusInternalServerError, "Export failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conditional Enrichment Pattern
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Для warehouse прайслистов добавляем дополнительные поля
|
||||||
|
func (r *PricelistRepository) enrichWarehouseItems(items []models.PricelistItem) error {
|
||||||
|
// 1. Собрать уникальные lot_names
|
||||||
|
lots := make([]string, 0, len(items))
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
for _, item := range items {
|
||||||
|
if _, ok := seen[item.LotName]; !ok {
|
||||||
|
lots = append(lots, item.LotName)
|
||||||
|
seen[item.LotName] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Batch-загрузка метрик (qty, partnumbers)
|
||||||
|
qtyByLot, partnumbersByLot, err := warehouse.LoadLotMetrics(r.db, lots, true)
|
||||||
|
|
||||||
|
// 3. Обогащение items
|
||||||
|
for i := range items {
|
||||||
|
if qty, ok := qtyByLot[items[i].LotName]; ok {
|
||||||
|
items[i].AvailableQty = &qty
|
||||||
|
}
|
||||||
|
items[i].Partnumbers = partnumbersByLot[items[i].LotName]
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Virtual Fields Pattern
|
||||||
|
|
||||||
|
```go
|
||||||
|
type PricelistItem struct {
|
||||||
|
// Stored fields
|
||||||
|
ID uint `gorm:"primaryKey"`
|
||||||
|
LotName string `gorm:"size:255"`
|
||||||
|
Price float64 `gorm:"type:decimal(12,2)"`
|
||||||
|
|
||||||
|
// Virtual fields (populated via JOIN or programmatically)
|
||||||
|
LotDescription string `gorm:"-:migration" json:"lot_description,omitempty"`
|
||||||
|
Category string `gorm:"-:migration" json:"category,omitempty"`
|
||||||
|
AvailableQty *float64 `gorm:"-" json:"available_qty,omitempty"`
|
||||||
|
Partnumbers []string `gorm:"-" json:"partnumbers,omitempty"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `gorm:"-:migration"` - не создавать колонку в БД, но маппить при SELECT
|
||||||
|
- `gorm:"-"` - полностью игнорировать при БД операциях
|
||||||
|
|
||||||
|
## Checklist для CSV Export
|
||||||
|
|
||||||
|
- [ ] HTTP заголовки: Content-Type, Content-Disposition
|
||||||
|
- [ ] UTF-8 BOM для Excel (0xEF, 0xBB, 0xBF)
|
||||||
|
- [ ] Разделитель (`;` для русской локали Excel)
|
||||||
|
- [ ] Streaming с batch processing (не загружать всё в память)
|
||||||
|
- [ ] JOIN для избежания N+1 запросов
|
||||||
|
- [ ] Flush после каждого batch
|
||||||
|
- [ ] Graceful error handling (нельзя JSON после начала streaming)
|
||||||
|
- [ ] Динамические заголовки (если нужно)
|
||||||
|
- [ ] Conditional enrichment (если данные зависят от типа)
|
||||||
|
|
||||||
|
## Когда использовать этот паттерн
|
||||||
|
|
||||||
|
✅ **Используй когда:**
|
||||||
|
- Экспорт больших датасетов (>1000 записей)
|
||||||
|
- Нужна Excel-совместимость
|
||||||
|
- Связанные данные из нескольких таблиц
|
||||||
|
- Conditional логика enrichment
|
||||||
|
|
||||||
|
❌ **Не нужен когда:**
|
||||||
|
- Малые датасеты (<100 записей) - можно загрузить всё сразу
|
||||||
|
- Экспорт JSON/XML - другие подходы
|
||||||
|
- Нет связанных данных - можно упростить
|
||||||
|
|
||||||
|
## Пример роутинга (Gin)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// В файле роутера
|
||||||
|
func SetupRoutes(router *gin.Engine, handler *PricelistHandler) {
|
||||||
|
api := router.Group("/api")
|
||||||
|
{
|
||||||
|
pricelists := api.Group("/pricelists")
|
||||||
|
{
|
||||||
|
pricelists.GET("/:id/export", handler.ExportCSV)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Импорты
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Notes
|
||||||
|
|
||||||
|
1. **Batch Size**: 500-1000 оптимально для большинства случаев
|
||||||
|
2. **JOIN vs N+1**: JOIN на порядки быстрее при >100 записях
|
||||||
|
3. **Memory**: Streaming позволяет экспортировать миллионы записей с минимальной памятью
|
||||||
|
4. **Indexes**: Убедись что есть индексы на JOIN колонках
|
||||||
|
|
||||||
|
## Источник
|
||||||
|
|
||||||
|
Реализовано в проекте PriceForge:
|
||||||
|
- Handler: `internal/handlers/pricelist.go:245-346`
|
||||||
|
- Service: `internal/services/pricelist/service.go:373-379`
|
||||||
|
- Repository: `internal/repository/pricelist.go:475-533`
|
||||||
|
- Models: `internal/models/pricelist.go`
|
||||||
BIN
dist/qfs-darwin-amd64
vendored
Executable file
BIN
dist/qfs-darwin-amd64
vendored
Executable file
Binary file not shown.
BIN
dist/qfs-darwin-arm64
vendored
Executable file
BIN
dist/qfs-darwin-arm64
vendored
Executable file
Binary file not shown.
BIN
dist/qfs-linux-amd64
vendored
Executable file
BIN
dist/qfs-linux-amd64
vendored
Executable file
Binary file not shown.
BIN
dist/qfs-windows-amd64.exe
vendored
Executable file
BIN
dist/qfs-windows-amd64.exe
vendored
Executable file
Binary file not shown.
26
internal/appmeta/version.go
Normal file
26
internal/appmeta/version.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package appmeta
|
||||||
|
|
||||||
|
import "sync/atomic"
|
||||||
|
|
||||||
|
var appVersion atomic.Value
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
appVersion.Store("dev")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetVersion configures the running application version string.
|
||||||
|
func SetVersion(v string) {
|
||||||
|
if v == "" {
|
||||||
|
v = "dev"
|
||||||
|
}
|
||||||
|
appVersion.Store(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version returns the running application version string.
|
||||||
|
func Version() string {
|
||||||
|
if v, ok := appVersion.Load().(string); ok && v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return "dev"
|
||||||
|
}
|
||||||
|
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -55,6 +56,25 @@ func ResolveConfigPath(explicitPath string) (string, error) {
|
|||||||
return filepath.Join(dir, defaultCfg), nil
|
return filepath.Join(dir, defaultCfg), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResolveConfigPathNearDB returns config path using priority:
|
||||||
|
// explicit CLI path > QFS_CONFIG_PATH > directory of resolved local DB path.
|
||||||
|
// Falls back to ResolveConfigPath when dbPath is empty.
|
||||||
|
func ResolveConfigPathNearDB(explicitPath, dbPath string) (string, error) {
|
||||||
|
if explicitPath != "" {
|
||||||
|
return filepath.Clean(explicitPath), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if fromEnv := os.Getenv(envCfgPath); fromEnv != "" {
|
||||||
|
return filepath.Clean(fromEnv), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(dbPath) != "" {
|
||||||
|
return filepath.Join(filepath.Dir(filepath.Clean(dbPath)), defaultCfg), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResolveConfigPath("")
|
||||||
|
}
|
||||||
|
|
||||||
// MigrateLegacyDB copies an existing legacy DB (and optional SQLite sidecars)
|
// MigrateLegacyDB copies an existing legacy DB (and optional SQLite sidecars)
|
||||||
// to targetPath if targetPath does not already exist.
|
// to targetPath if targetPath does not already exist.
|
||||||
// Returns source path if migration happened.
|
// Returns source path if migration happened.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,9 @@ func NewExportHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ExportRequest struct {
|
type ExportRequest struct {
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
Items []struct {
|
ProjectName string `json:"project_name"`
|
||||||
|
Items []struct {
|
||||||
LotName string `json:"lot_name" binding:"required"`
|
LotName string `json:"lot_name" binding:"required"`
|
||||||
Quantity int `json:"quantity" binding:"required,min=1"`
|
Quantity int `json:"quantity" binding:"required,min=1"`
|
||||||
UnitPrice float64 `json:"unit_price"`
|
UnitPrice float64 `json:"unit_price"`
|
||||||
@@ -47,15 +48,26 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
|||||||
|
|
||||||
data := h.buildExportData(&req)
|
data := h.buildExportData(&req)
|
||||||
|
|
||||||
csvData, err := h.exportService.ToCSV(data)
|
// Validate before streaming (can return JSON error)
|
||||||
if err != nil {
|
if len(data.Items) == 0 {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
filename := fmt.Sprintf("%s %s SPEC.csv", time.Now().Format("2006-01-02"), req.Name)
|
// Set headers before streaming
|
||||||
|
projectName := req.ProjectName
|
||||||
|
if projectName == "" {
|
||||||
|
projectName = req.Name
|
||||||
|
}
|
||||||
|
filename := fmt.Sprintf("%s (%s) %s BOM.csv", time.Now().Format("2006-01-02"), projectName, req.Name)
|
||||||
|
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||||
c.Data(http.StatusOK, "text/csv; charset=utf-8", csvData)
|
|
||||||
|
// Stream CSV (cannot return JSON after this point)
|
||||||
|
if err := h.exportService.ToCSV(c.Writer, data); err != nil {
|
||||||
|
c.Error(err) // Log only
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData {
|
func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData {
|
||||||
@@ -101,6 +113,7 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
|
|||||||
username := middleware.GetUsername(c)
|
username := middleware.GetUsername(c)
|
||||||
uuid := c.Param("uuid")
|
uuid := c.Param("uuid")
|
||||||
|
|
||||||
|
// Get config before streaming (can return JSON error)
|
||||||
config, err := h.configService.GetByUUID(uuid, username)
|
config, err := h.configService.GetByUUID(uuid, username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
@@ -109,13 +122,21 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
|
|||||||
|
|
||||||
data := h.exportService.ConfigToExportData(config, h.componentService)
|
data := h.exportService.ConfigToExportData(config, h.componentService)
|
||||||
|
|
||||||
csvData, err := h.exportService.ToCSV(data)
|
// Validate before streaming (can return JSON error)
|
||||||
if err != nil {
|
if len(data.Items) == 0 {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
filename := fmt.Sprintf("%s %s SPEC.csv", config.CreatedAt.Format("2006-01-02"), config.Name)
|
// Set headers before streaming
|
||||||
|
// For config export, use config name for both project and quotation name
|
||||||
|
filename := fmt.Sprintf("%s (%s) %s BOM.csv", config.CreatedAt.Format("2006-01-02"), config.Name, config.Name)
|
||||||
|
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||||
c.Data(http.StatusOK, "text/csv; charset=utf-8", csvData)
|
|
||||||
|
// Stream CSV (cannot return JSON after this point)
|
||||||
|
if err := h.exportService.ToCSV(c.Writer, data); err != nil {
|
||||||
|
c.Error(err) // Log only
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
308
internal/handlers/export_test.go
Normal file
308
internal/handlers/export_test.go
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/csv"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mock services for testing
|
||||||
|
type mockConfigService struct {
|
||||||
|
config *models.Configuration
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockConfigService) GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error) {
|
||||||
|
return m.config, m.err
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func TestExportCSV_Success(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
// Create a basic mock component service that doesn't panic
|
||||||
|
mockComponentService := &services.ComponentService{}
|
||||||
|
|
||||||
|
// Create handler with mocks
|
||||||
|
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
||||||
|
handler := NewExportHandler(
|
||||||
|
exportSvc,
|
||||||
|
&mockConfigService{},
|
||||||
|
mockComponentService,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create JSON request body
|
||||||
|
jsonBody := `{
|
||||||
|
"name": "Test Export",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"lot_name": "LOT-001",
|
||||||
|
"quantity": 2,
|
||||||
|
"unit_price": 100.50
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"notes": "Test notes"
|
||||||
|
}`
|
||||||
|
|
||||||
|
// Create HTTP request
|
||||||
|
req, _ := http.NewRequest("POST", "/api/export/csv", bytes.NewBufferString(jsonBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
// Create response recorder
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Create Gin context
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = req
|
||||||
|
|
||||||
|
// Call handler
|
||||||
|
handler.ExportCSV(c)
|
||||||
|
|
||||||
|
// Check status code
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Content-Type header
|
||||||
|
contentType := w.Header().Get("Content-Type")
|
||||||
|
if contentType != "text/csv; charset=utf-8" {
|
||||||
|
t.Errorf("Expected Content-Type 'text/csv; charset=utf-8', got %q", contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for BOM
|
||||||
|
responseBody := w.Body.Bytes()
|
||||||
|
if len(responseBody) < 3 {
|
||||||
|
t.Fatalf("Response too short to contain BOM")
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
||||||
|
actualBOM := responseBody[:3]
|
||||||
|
if bytes.Compare(actualBOM, expectedBOM) != 0 {
|
||||||
|
t.Errorf("UTF-8 BOM mismatch. Expected %v, got %v", expectedBOM, actualBOM)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check semicolon delimiter in CSV
|
||||||
|
reader := csv.NewReader(bytes.NewReader(responseBody[3:]))
|
||||||
|
reader.Comma = ';'
|
||||||
|
|
||||||
|
header, err := reader.Read()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Failed to parse CSV header: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(header) != 6 {
|
||||||
|
t.Errorf("Expected 6 columns, got %d", len(header))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportCSV_InvalidRequest(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
||||||
|
handler := NewExportHandler(
|
||||||
|
exportSvc,
|
||||||
|
&mockConfigService{},
|
||||||
|
&services.ComponentService{},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create invalid request (missing required field)
|
||||||
|
req, _ := http.NewRequest("POST", "/api/export/csv", bytes.NewBufferString(`{"name": "Test"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = req
|
||||||
|
|
||||||
|
handler.ExportCSV(c)
|
||||||
|
|
||||||
|
// Should return 400 Bad Request
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("Expected status 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should return JSON error
|
||||||
|
var errResp map[string]interface{}
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &errResp)
|
||||||
|
if _, hasError := errResp["error"]; !hasError {
|
||||||
|
t.Errorf("Expected error in JSON response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportCSV_EmptyItems(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
||||||
|
handler := NewExportHandler(
|
||||||
|
exportSvc,
|
||||||
|
&mockConfigService{},
|
||||||
|
&services.ComponentService{},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create request with empty items array - should fail binding validation
|
||||||
|
req, _ := http.NewRequest("POST", "/api/export/csv", bytes.NewBufferString(`{"name":"Empty Export","items":[],"notes":""}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = req
|
||||||
|
|
||||||
|
handler.ExportCSV(c)
|
||||||
|
|
||||||
|
// Should return 400 Bad Request (validation error from gin binding)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Logf("Status code: %d (expected 400 for empty items)", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportConfigCSV_Success(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
// Mock configuration
|
||||||
|
mockConfig := &models.Configuration{
|
||||||
|
UUID: "test-uuid",
|
||||||
|
Name: "Test Config",
|
||||||
|
OwnerUsername: "testuser",
|
||||||
|
Items: models.ConfigItems{
|
||||||
|
{
|
||||||
|
LotName: "LOT-001",
|
||||||
|
Quantity: 1,
|
||||||
|
UnitPrice: 100.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
||||||
|
handler := NewExportHandler(
|
||||||
|
exportSvc,
|
||||||
|
&mockConfigService{config: mockConfig},
|
||||||
|
&services.ComponentService{},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create HTTP request
|
||||||
|
req, _ := http.NewRequest("GET", "/api/configs/test-uuid/export", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = req
|
||||||
|
c.Params = gin.Params{
|
||||||
|
{Key: "uuid", Value: "test-uuid"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock middleware.GetUsername
|
||||||
|
c.Set("username", "testuser")
|
||||||
|
|
||||||
|
handler.ExportConfigCSV(c)
|
||||||
|
|
||||||
|
// Check status code
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Content-Type header
|
||||||
|
contentType := w.Header().Get("Content-Type")
|
||||||
|
if contentType != "text/csv; charset=utf-8" {
|
||||||
|
t.Errorf("Expected Content-Type 'text/csv; charset=utf-8', got %q", contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for BOM
|
||||||
|
responseBody := w.Body.Bytes()
|
||||||
|
if len(responseBody) < 3 {
|
||||||
|
t.Fatalf("Response too short to contain BOM")
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
||||||
|
actualBOM := responseBody[:3]
|
||||||
|
if bytes.Compare(actualBOM, expectedBOM) != 0 {
|
||||||
|
t.Errorf("UTF-8 BOM mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportConfigCSV_NotFound(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
||||||
|
handler := NewExportHandler(
|
||||||
|
exportSvc,
|
||||||
|
&mockConfigService{err: errors.New("config not found")},
|
||||||
|
&services.ComponentService{},
|
||||||
|
)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", "/api/configs/nonexistent-uuid/export", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = req
|
||||||
|
c.Params = gin.Params{
|
||||||
|
{Key: "uuid", Value: "nonexistent-uuid"},
|
||||||
|
}
|
||||||
|
c.Set("username", "testuser")
|
||||||
|
|
||||||
|
handler.ExportConfigCSV(c)
|
||||||
|
|
||||||
|
// Should return 404 Not Found
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("Expected status 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should return JSON error
|
||||||
|
var errResp map[string]interface{}
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &errResp)
|
||||||
|
if _, hasError := errResp["error"]; !hasError {
|
||||||
|
t.Errorf("Expected error in JSON response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportConfigCSV_EmptyItems(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
// Mock configuration with empty items
|
||||||
|
mockConfig := &models.Configuration{
|
||||||
|
UUID: "test-uuid",
|
||||||
|
Name: "Empty Config",
|
||||||
|
OwnerUsername: "testuser",
|
||||||
|
Items: models.ConfigItems{},
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
||||||
|
handler := NewExportHandler(
|
||||||
|
exportSvc,
|
||||||
|
&mockConfigService{config: mockConfig},
|
||||||
|
&services.ComponentService{},
|
||||||
|
)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", "/api/configs/test-uuid/export", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = req
|
||||||
|
c.Params = gin.Params{
|
||||||
|
{Key: "uuid", Value: "test-uuid"},
|
||||||
|
}
|
||||||
|
c.Set("username", "testuser")
|
||||||
|
|
||||||
|
handler.ExportConfigCSV(c)
|
||||||
|
|
||||||
|
// Should return 400 Bad Request
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("Expected status 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should return JSON error
|
||||||
|
var errResp map[string]interface{}
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &errResp)
|
||||||
|
if _, hasError := errResp["error"]; !hasError {
|
||||||
|
t.Errorf("Expected error in JSON response")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
64
internal/handlers/sync_readiness_test.go
Normal file
64
internal/handlers/sync_readiness_test.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSyncReadinessOfflineBlocked(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
local, err := localdb.New(filepath.Join(dir, "qfs.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("init local db: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
service := syncsvc.NewService(nil, local)
|
||||||
|
h, err := NewSyncHandler(local, service, nil, filepath.Join("web", "templates"), 5*time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new sync handler: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
router := gin.New()
|
||||||
|
router.GET("/api/sync/readiness", h.GetReadiness)
|
||||||
|
router.POST("/api/sync/push", h.PushPendingChanges)
|
||||||
|
|
||||||
|
readinessResp := httptest.NewRecorder()
|
||||||
|
readinessReq, _ := http.NewRequest(http.MethodGet, "/api/sync/readiness", nil)
|
||||||
|
router.ServeHTTP(readinessResp, readinessReq)
|
||||||
|
if readinessResp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("unexpected readiness status: %d", readinessResp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var readinessBody map[string]any
|
||||||
|
if err := json.Unmarshal(readinessResp.Body.Bytes(), &readinessBody); err != nil {
|
||||||
|
t.Fatalf("decode readiness body: %v", err)
|
||||||
|
}
|
||||||
|
if blocked, _ := readinessBody["blocked"].(bool); !blocked {
|
||||||
|
t.Fatalf("expected blocked readiness, got %v", readinessBody["blocked"])
|
||||||
|
}
|
||||||
|
|
||||||
|
pushResp := httptest.NewRecorder()
|
||||||
|
pushReq, _ := http.NewRequest(http.MethodPost, "/api/sync/push", nil)
|
||||||
|
router.ServeHTTP(pushResp, pushReq)
|
||||||
|
if pushResp.Code != http.StatusLocked {
|
||||||
|
t.Fatalf("expected 423 for blocked sync push, got %d body=%s", pushResp.Code, pushResp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var pushBody map[string]any
|
||||||
|
if err := json.Unmarshal(pushResp.Body.Bytes(), &pushBody); err != nil {
|
||||||
|
t.Fatalf("decode push body: %v", err)
|
||||||
|
}
|
||||||
|
if pushBody["reason_text"] == nil || pushBody["reason_text"] == "" {
|
||||||
|
t.Fatalf("expected reason_text in blocked response, got %v", pushBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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: ¬e,
|
ChangeNote: ¬e,
|
||||||
|
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
|
||||||
@@ -400,6 +719,47 @@ func (l *LocalDB) DeleteLocalPricelist(id uint) error {
|
|||||||
return l.db.Delete(&LocalPricelist{}, id).Error
|
return l.db.Delete(&LocalPricelist{}, id).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteUnusedLocalPricelistsMissingOnServer removes local pricelists that are absent on server
|
||||||
|
// and not referenced by active local configurations.
|
||||||
|
func (l *LocalDB) DeleteUnusedLocalPricelistsMissingOnServer(serverPricelistIDs []uint) (int, error) {
|
||||||
|
returned := 0
|
||||||
|
err := l.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var candidates []LocalPricelist
|
||||||
|
query := tx.Model(&LocalPricelist{})
|
||||||
|
if len(serverPricelistIDs) > 0 {
|
||||||
|
query = query.Where("server_id NOT IN ?", serverPricelistIDs)
|
||||||
|
}
|
||||||
|
if err := query.Find(&candidates).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range candidates {
|
||||||
|
pl := candidates[i]
|
||||||
|
var refs int64
|
||||||
|
if err := tx.Model(&LocalConfiguration{}).
|
||||||
|
Where("pricelist_id = ? AND is_active = 1", pl.ServerID).
|
||||||
|
Count(&refs).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if refs > 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := tx.Where("pricelist_id = ?", pl.ID).Delete(&LocalPricelistItem{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Delete(&LocalPricelist{}, pl.ID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
returned++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return returned, nil
|
||||||
|
}
|
||||||
|
|
||||||
// PendingChange methods
|
// PendingChange methods
|
||||||
|
|
||||||
// AddPendingChange adds a change to the sync queue
|
// AddPendingChange adds a change to the sync queue
|
||||||
@@ -472,7 +832,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
|
||||||
|
}
|
||||||
|
|||||||
60
internal/localdb/migration_projects_test.go
Normal file
60
internal/localdb/migration_projects_test.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package localdb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunLocalMigrationsBackfillsDefaultProject(t *testing.T) {
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "projects_backfill.db")
|
||||||
|
|
||||||
|
local, err := New(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open localdb: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = local.Close() })
|
||||||
|
|
||||||
|
cfg := &LocalConfiguration{
|
||||||
|
UUID: "cfg-without-project",
|
||||||
|
Name: "Cfg no project",
|
||||||
|
Items: LocalConfigItems{},
|
||||||
|
SyncStatus: "pending",
|
||||||
|
OriginalUsername: "tester",
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
if err := local.SaveConfiguration(cfg); err != nil {
|
||||||
|
t.Fatalf("save config: %v", err)
|
||||||
|
}
|
||||||
|
if err := local.DB().
|
||||||
|
Model(&LocalConfiguration{}).
|
||||||
|
Where("uuid = ?", cfg.UUID).
|
||||||
|
Update("project_uuid", nil).Error; err != nil {
|
||||||
|
t.Fatalf("clear project_uuid: %v", err)
|
||||||
|
}
|
||||||
|
if err := local.DB().Where("id = ?", "2026_02_06_projects_backfill").Delete(&LocalSchemaMigration{}).Error; err != nil {
|
||||||
|
t.Fatalf("delete local migration record: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runLocalMigrations(local.DB()); err != nil {
|
||||||
|
t.Fatalf("run local migrations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := local.GetConfigurationByUUID(cfg.UUID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get updated config: %v", err)
|
||||||
|
}
|
||||||
|
if updated.ProjectUUID == nil || *updated.ProjectUUID == "" {
|
||||||
|
t.Fatalf("expected project_uuid to be backfilled")
|
||||||
|
}
|
||||||
|
|
||||||
|
project, err := local.GetProjectByUUID(*updated.ProjectUUID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get system project: %v", err)
|
||||||
|
}
|
||||||
|
if project.Name != "Без проекта" {
|
||||||
|
t.Fatalf("expected system project name, got %q", project.Name)
|
||||||
|
}
|
||||||
|
if !project.IsSystem {
|
||||||
|
t.Fatalf("expected system project flag")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: ¬e,
|
ChangeNote: ¬e,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
238
internal/lotmatch/resolver.go
Normal file
238
internal/lotmatch/resolver.go
Normal 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
|
||||||
|
}
|
||||||
62
internal/lotmatch/resolver_test.go
Normal file
62
internal/lotmatch/resolver_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +1,55 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func CORS() gin.HandlerFunc {
|
func CORS() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
c.Header("Access-Control-Allow-Origin", "*")
|
origin := strings.TrimSpace(c.GetHeader("Origin"))
|
||||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
if origin != "" {
|
||||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization")
|
if isLoopbackOrigin(origin) {
|
||||||
c.Header("Access-Control-Expose-Headers", "Content-Length, Content-Disposition")
|
c.Header("Access-Control-Allow-Origin", origin)
|
||||||
c.Header("Access-Control-Max-Age", "86400")
|
c.Header("Vary", "Origin")
|
||||||
|
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||||
|
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization")
|
||||||
|
c.Header("Access-Control-Expose-Headers", "Content-Length, Content-Disposition")
|
||||||
|
c.Header("Access-Control-Max-Age", "86400")
|
||||||
|
} else if c.Request.Method == http.MethodOptions {
|
||||||
|
c.AbortWithStatus(http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if c.Request.Method == "OPTIONS" {
|
if c.Request.Method == http.MethodOptions {
|
||||||
c.AbortWithStatus(204)
|
c.AbortWithStatus(http.StatusNoContent)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isLoopbackOrigin(origin string) bool {
|
||||||
|
u, err := url.Parse(origin)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if u.Scheme != "http" && u.Scheme != "https" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
host := strings.TrimSpace(u.Hostname())
|
||||||
|
if host == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.EqualFold(host, "localhost") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(host)
|
||||||
|
return ip != nil && ip.IsLoopback()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ func AllModels() []interface{} {
|
|||||||
&User{},
|
&User{},
|
||||||
&Category{},
|
&Category{},
|
||||||
&LotMetadata{},
|
&LotMetadata{},
|
||||||
|
&Project{},
|
||||||
&Configuration{},
|
&Configuration{},
|
||||||
&PriceOverride{},
|
&PriceOverride{},
|
||||||
&PricingAlert{},
|
&PricingAlert{},
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
19
internal/models/project.go
Normal file
19
internal/models/project.go
Normal 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"
|
||||||
|
}
|
||||||
227
internal/models/sql_migrations.go
Normal file
227
internal/models/sql_migrations.go
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SQLSchemaMigration struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||||
|
Filename string `gorm:"size:255;uniqueIndex;not null"`
|
||||||
|
AppliedAt time.Time `gorm:"autoCreateTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (SQLSchemaMigration) TableName() string {
|
||||||
|
return "qt_schema_migrations"
|
||||||
|
}
|
||||||
|
|
||||||
|
// NeedsSQLMigrations reports whether at least one SQL migration from migrationsDir
|
||||||
|
// is not yet recorded in qt_schema_migrations.
|
||||||
|
func NeedsSQLMigrations(db *gorm.DB, migrationsDir string) (bool, error) {
|
||||||
|
files, err := listSQLMigrationFiles(migrationsDir)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if len(files) == 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If tracking table does not exist yet, migrations are required.
|
||||||
|
if !db.Migrator().HasTable(&SQLSchemaMigration{}) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
if err := db.Model(&SQLSchemaMigration{}).Where("filename IN ?", files).Count(&count).Error; err != nil {
|
||||||
|
return false, fmt.Errorf("check applied migrations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return count < int64(len(files)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunSQLMigrations applies SQL files from migrationsDir once and records them in qt_schema_migrations.
|
||||||
|
// Local SQLite-only scripts are skipped automatically.
|
||||||
|
func RunSQLMigrations(db *gorm.DB, migrationsDir string) error {
|
||||||
|
if err := ensureSQLMigrationsTable(db); err != nil {
|
||||||
|
return fmt.Errorf("migrate qt_schema_migrations table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := listSQLMigrationFiles(migrationsDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, filename := range files {
|
||||||
|
var count int64
|
||||||
|
if err := db.Model(&SQLSchemaMigration{}).Where("filename = ?", filename).Count(&count).Error; err != nil {
|
||||||
|
return fmt.Errorf("check migration %s: %w", filename, err)
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(migrationsDir, filename)
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read migration %s: %w", filename, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
statements := splitSQLStatements(string(content))
|
||||||
|
if len(statements) == 0 {
|
||||||
|
if err := db.Create(&SQLSchemaMigration{Filename: filename}).Error; err != nil {
|
||||||
|
return fmt.Errorf("record empty migration %s: %w", filename, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := executeMigrationStatements(db, filename, statements); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := db.Create(&SQLSchemaMigration{Filename: filename}).Error; err != nil {
|
||||||
|
return fmt.Errorf("record migration %s: %w", filename, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsMigrationPermissionError returns true if err indicates insufficient privileges
|
||||||
|
// to create/alter/read migration metadata or target schema objects.
|
||||||
|
func IsMigrationPermissionError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var mysqlErr *mysqlDriver.MySQLError
|
||||||
|
if errors.As(err, &mysqlErr) {
|
||||||
|
switch mysqlErr.Number {
|
||||||
|
case 1044, 1045, 1142, 1143, 1227:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lower := strings.ToLower(err.Error())
|
||||||
|
patterns := []string{
|
||||||
|
"command denied to user",
|
||||||
|
"access denied for user",
|
||||||
|
"permission denied",
|
||||||
|
"insufficient privilege",
|
||||||
|
"sqlstate 42000",
|
||||||
|
}
|
||||||
|
for _, pattern := range patterns {
|
||||||
|
if strings.Contains(lower, pattern) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureSQLMigrationsTable(db *gorm.DB) error {
|
||||||
|
stmt := `
|
||||||
|
CREATE TABLE IF NOT EXISTS qt_schema_migrations (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
filename VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);`
|
||||||
|
return db.Exec(stmt).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeMigrationStatements(db *gorm.DB, filename string, statements []string) error {
|
||||||
|
for _, stmt := range statements {
|
||||||
|
if err := db.Exec(stmt).Error; err != nil {
|
||||||
|
if isIgnorableMigrationError(err.Error()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return fmt.Errorf("exec migration %s statement %q: %w", filename, stmt, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSQLiteOnlyMigration(filename string) bool {
|
||||||
|
lower := strings.ToLower(filename)
|
||||||
|
return strings.Contains(lower, "local_")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isIgnorableMigrationError(message string) bool {
|
||||||
|
lower := strings.ToLower(message)
|
||||||
|
ignorable := []string{
|
||||||
|
"duplicate column name",
|
||||||
|
"duplicate key name",
|
||||||
|
"already exists",
|
||||||
|
"can't create table",
|
||||||
|
"duplicate foreign key constraint name",
|
||||||
|
"errno 121",
|
||||||
|
}
|
||||||
|
for _, pattern := range ignorable {
|
||||||
|
if strings.Contains(lower, pattern) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitSQLStatements(script string) []string {
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(script))
|
||||||
|
scanner.Buffer(make([]byte, 1024), 1024*1024)
|
||||||
|
|
||||||
|
lines := make([]string, 0, 128)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "--") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, scanner.Text())
|
||||||
|
}
|
||||||
|
|
||||||
|
combined := strings.Join(lines, "\n")
|
||||||
|
raw := strings.Split(combined, ";")
|
||||||
|
stmts := make([]string, 0, len(raw))
|
||||||
|
for _, stmt := range raw {
|
||||||
|
trimmed := strings.TrimSpace(stmt)
|
||||||
|
if trimmed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stmts = append(stmts, trimmed)
|
||||||
|
}
|
||||||
|
return stmts
|
||||||
|
}
|
||||||
|
|
||||||
|
func listSQLMigrationFiles(migrationsDir string) ([]string, error) {
|
||||||
|
entries, err := os.ReadDir(migrationsDir)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read migrations dir %s: %w", migrationsDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files := make([]string, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := entry.Name()
|
||||||
|
if !strings.HasSuffix(strings.ToLower(name), ".sql") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isSQLiteOnlyMigration(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
files = append(files, name)
|
||||||
|
}
|
||||||
|
sort.Strings(files)
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
@@ -110,6 +110,10 @@ func (r *ComponentRepository) Update(component *models.LotMetadata) error {
|
|||||||
return r.db.Save(component).Error
|
return r.db.Save(component).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *ComponentRepository) DB() *gorm.DB {
|
||||||
|
return r.db
|
||||||
|
}
|
||||||
|
|
||||||
func (r *ComponentRepository) Create(component *models.LotMetadata) error {
|
func (r *ComponentRepository) Create(component *models.LotMetadata) error {
|
||||||
return r.db.Create(component).Error
|
return r.db.Create(component).Error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func (r *ConfigurationRepository) Create(config *models.Configuration) error {
|
|||||||
|
|
||||||
func (r *ConfigurationRepository) GetByID(id uint) (*models.Configuration, error) {
|
func (r *ConfigurationRepository) GetByID(id uint) (*models.Configuration, error) {
|
||||||
var config models.Configuration
|
var config models.Configuration
|
||||||
err := r.db.Preload("User").First(&config, id).Error
|
err := r.db.First(&config, id).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -28,7 +28,7 @@ func (r *ConfigurationRepository) GetByID(id uint) (*models.Configuration, error
|
|||||||
|
|
||||||
func (r *ConfigurationRepository) GetByUUID(uuid string) (*models.Configuration, error) {
|
func (r *ConfigurationRepository) GetByUUID(uuid string) (*models.Configuration, error) {
|
||||||
var config models.Configuration
|
var config models.Configuration
|
||||||
err := r.db.Preload("User").Where("uuid = ?", uuid).First(&config).Error
|
err := r.db.Where("uuid = ?", uuid).First(&config).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -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).
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
package repository
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/lotmatch"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -19,13 +23,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 +49,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 +89,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 +119,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 +142,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 +172,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
|
||||||
@@ -203,21 +245,199 @@ func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, sear
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := r.enrichItemsWithStock(items); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("enriching pricelist items with stock: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return items, total, nil
|
return items, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *PricelistRepository) enrichItemsWithStock(items []models.PricelistItem) error {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
resolver, err := lotmatch.NewLotResolverFromDB(r.db)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type stockRow struct {
|
||||||
|
Partnumber string `gorm:"column:partnumber"`
|
||||||
|
Qty *float64 `gorm:"column:qty"`
|
||||||
|
}
|
||||||
|
rows := make([]stockRow, 0)
|
||||||
|
if err := r.db.Raw(`
|
||||||
|
SELECT s.partnumber, s.qty
|
||||||
|
FROM stock_log s
|
||||||
|
INNER JOIN (
|
||||||
|
SELECT partnumber, MAX(date) AS max_date
|
||||||
|
FROM stock_log
|
||||||
|
GROUP BY partnumber
|
||||||
|
) latest ON latest.partnumber = s.partnumber AND latest.max_date = s.date
|
||||||
|
WHERE s.qty IS NOT NULL
|
||||||
|
`).Scan(&rows).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
lotTotals := make(map[string]float64, len(items))
|
||||||
|
lotPartnumbers := make(map[string][]string, len(items))
|
||||||
|
seenPartnumbers := make(map[string]map[string]struct{}, len(items))
|
||||||
|
|
||||||
|
for i := range rows {
|
||||||
|
row := rows[i]
|
||||||
|
if strings.TrimSpace(row.Partnumber) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lotName, _, resolveErr := resolver.Resolve(row.Partnumber)
|
||||||
|
if resolveErr != nil || strings.TrimSpace(lotName) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if row.Qty != nil {
|
||||||
|
lotTotals[lotName] += *row.Qty
|
||||||
|
}
|
||||||
|
|
||||||
|
pn := strings.TrimSpace(row.Partnumber)
|
||||||
|
if pn == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seenPartnumbers[lotName]; !ok {
|
||||||
|
seenPartnumbers[lotName] = make(map[string]struct{}, 4)
|
||||||
|
}
|
||||||
|
key := strings.ToLower(pn)
|
||||||
|
if _, exists := seenPartnumbers[lotName][key]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenPartnumbers[lotName][key] = struct{}{}
|
||||||
|
lotPartnumbers[lotName] = append(lotPartnumbers[lotName], pn)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range items {
|
||||||
|
lotName := items[i].LotName
|
||||||
|
if qty, ok := lotTotals[lotName]; ok {
|
||||||
|
qtyCopy := qty
|
||||||
|
items[i].AvailableQty = &qtyCopy
|
||||||
|
}
|
||||||
|
if partnumbers := lotPartnumbers[lotName]; len(partnumbers) > 0 {
|
||||||
|
sort.Slice(partnumbers, func(a, b int) bool {
|
||||||
|
return strings.ToLower(partnumbers[a]) < strings.ToLower(partnumbers[b])
|
||||||
|
})
|
||||||
|
items[i].Partnumbers = partnumbers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLotNames returns distinct lot names from pricelist items.
|
||||||
|
func (r *PricelistRepository) GetLotNames(pricelistID uint) ([]string, error) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPriceForLot returns item price for a lot within a pricelist.
|
||||||
|
func (r *PricelistRepository) GetPriceForLot(pricelistID uint, lotName string) (float64, error) {
|
||||||
|
var item models.PricelistItem
|
||||||
|
if err := r.db.Where("pricelist_id = ? AND lot_name = ?", pricelistID, lotName).First(&item).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return item.Price, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
// GenerateVersion generates a new version string in format YYYY-MM-DD-NNN
|
||||||
func (r *PricelistRepository) GenerateVersion() (string, error) {
|
func (r *PricelistRepository) GenerateVersion() (string, error) {
|
||||||
today := time.Now().Format("2006-01-02")
|
return r.GenerateVersionBySource(string(models.PricelistSourceEstimate))
|
||||||
|
}
|
||||||
|
|
||||||
var count int64
|
// GenerateVersionBySource generates a new version string in format YYYY-MM-DD-NNN scoped by source.
|
||||||
if err := r.db.Model(&models.Pricelist{}).
|
func (r *PricelistRepository) GenerateVersionBySource(source string) (string, error) {
|
||||||
Where("version LIKE ?", today+"%").
|
today := time.Now().Format("2006-01-02")
|
||||||
Count(&count).Error; err != nil {
|
prefix := versionPrefixBySource(source)
|
||||||
return "", fmt.Errorf("counting today's pricelists: %w", err)
|
|
||||||
|
var last models.Pricelist
|
||||||
|
err := r.db.Model(&models.Pricelist{}).
|
||||||
|
Select("version").
|
||||||
|
Where("source = ? AND version LIKE ?", source, 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("%s-%03d", today, count+1), nil
|
parts := strings.Split(last.Version, "-")
|
||||||
|
if len(parts) < 4 {
|
||||||
|
return "", fmt.Errorf("invalid pricelist version format: %s", last.Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := strconv.Atoi(parts[len(parts)-1])
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("parsing pricelist sequence %q: %w", parts[len(parts)-1], err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s-%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 +496,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
|
||||||
|
|||||||
140
internal/repository/pricelist_test.go
Normal file
140
internal/repository/pricelist_test.go
Normal 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)
|
||||||
|
}
|
||||||
194
internal/repository/project.go
Normal file
194
internal/repository/project.go
Normal 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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||||
@@ -40,14 +42,21 @@ type ExportItem struct {
|
|||||||
TotalPrice float64
|
TotalPrice float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExportService) ToCSV(data *ExportData) ([]byte, error) {
|
func (s *ExportService) ToCSV(w io.Writer, data *ExportData) error {
|
||||||
var buf bytes.Buffer
|
// Write UTF-8 BOM for Excel compatibility
|
||||||
w := csv.NewWriter(&buf)
|
if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
|
||||||
|
return fmt.Errorf("failed to write BOM: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
csvWriter := csv.NewWriter(w)
|
||||||
|
// Use semicolon as delimiter for Russian Excel locale
|
||||||
|
csvWriter.Comma = ';'
|
||||||
|
defer csvWriter.Flush()
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
headers := []string{"Артикул", "Описание", "Категория", "Количество", "Цена за единицу", "Сумма"}
|
headers := []string{"Артикул", "Описание", "Категория", "Количество", "Цена за единицу", "Сумма"}
|
||||||
if err := w.Write(headers); err != nil {
|
if err := csvWriter.Write(headers); err != nil {
|
||||||
return nil, err
|
return fmt.Errorf("failed to write header: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get category hierarchy for sorting
|
// Get category hierarchy for sorting
|
||||||
@@ -90,21 +99,35 @@ func (s *ExportService) ToCSV(data *ExportData) ([]byte, error) {
|
|||||||
item.Description,
|
item.Description,
|
||||||
item.Category,
|
item.Category,
|
||||||
fmt.Sprintf("%d", item.Quantity),
|
fmt.Sprintf("%d", item.Quantity),
|
||||||
fmt.Sprintf("%.2f", item.UnitPrice),
|
strings.ReplaceAll(fmt.Sprintf("%.2f", item.UnitPrice), ".", ","),
|
||||||
fmt.Sprintf("%.2f", item.TotalPrice),
|
strings.ReplaceAll(fmt.Sprintf("%.2f", item.TotalPrice), ".", ","),
|
||||||
}
|
}
|
||||||
if err := w.Write(row); err != nil {
|
if err := csvWriter.Write(row); err != nil {
|
||||||
return nil, err
|
return fmt.Errorf("failed to write row: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Total row
|
// Total row
|
||||||
if err := w.Write([]string{"", "", "", "", "ИТОГО:", fmt.Sprintf("%.2f", data.Total)}); err != nil {
|
totalStr := strings.ReplaceAll(fmt.Sprintf("%.2f", data.Total), ".", ",")
|
||||||
return nil, err
|
if err := csvWriter.Write([]string{"", "", "", "", "ИТОГО:", totalStr}); err != nil {
|
||||||
|
return fmt.Errorf("failed to write total row: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Flush()
|
csvWriter.Flush()
|
||||||
return buf.Bytes(), w.Error()
|
if err := csvWriter.Error(); err != nil {
|
||||||
|
return fmt.Errorf("csv writer error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToCSVBytes is a backward-compatible wrapper that returns CSV data as bytes
|
||||||
|
func (s *ExportService) ToCSVBytes(data *ExportData) ([]byte, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := s.ToCSV(&buf, data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExportService) ConfigToExportData(config *models.Configuration, componentService *ComponentService) *ExportData {
|
func (s *ExportService) ConfigToExportData(config *models.Configuration, componentService *ComponentService) *ExportData {
|
||||||
|
|||||||
343
internal/services/export_test.go
Normal file
343
internal/services/export_test.go
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/csv"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func TestToCSV_UTF8BOM(t *testing.T) {
|
||||||
|
svc := NewExportService(config.ExportConfig{}, nil)
|
||||||
|
|
||||||
|
data := &ExportData{
|
||||||
|
Name: "Test",
|
||||||
|
Items: []ExportItem{
|
||||||
|
{
|
||||||
|
LotName: "LOT-001",
|
||||||
|
Description: "Test Item",
|
||||||
|
Category: "CAT",
|
||||||
|
Quantity: 1,
|
||||||
|
UnitPrice: 100.0,
|
||||||
|
TotalPrice: 100.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Total: 100.0,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := svc.ToCSV(&buf, data); err != nil {
|
||||||
|
t.Fatalf("ToCSV failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
csvBytes := buf.Bytes()
|
||||||
|
if len(csvBytes) < 3 {
|
||||||
|
t.Fatalf("CSV too short to contain BOM")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check UTF-8 BOM: 0xEF 0xBB 0xBF
|
||||||
|
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
||||||
|
actualBOM := csvBytes[:3]
|
||||||
|
|
||||||
|
if bytes.Compare(actualBOM, expectedBOM) != 0 {
|
||||||
|
t.Errorf("UTF-8 BOM mismatch. Expected %v, got %v", expectedBOM, actualBOM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToCSV_SemicolonDelimiter(t *testing.T) {
|
||||||
|
svc := NewExportService(config.ExportConfig{}, nil)
|
||||||
|
|
||||||
|
data := &ExportData{
|
||||||
|
Name: "Test",
|
||||||
|
Items: []ExportItem{
|
||||||
|
{
|
||||||
|
LotName: "LOT-001",
|
||||||
|
Description: "Test Item",
|
||||||
|
Category: "CAT",
|
||||||
|
Quantity: 2,
|
||||||
|
UnitPrice: 100.50,
|
||||||
|
TotalPrice: 201.00,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Total: 201.00,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := svc.ToCSV(&buf, data); err != nil {
|
||||||
|
t.Fatalf("ToCSV failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip BOM and read CSV with semicolon delimiter
|
||||||
|
csvBytes := buf.Bytes()
|
||||||
|
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
||||||
|
reader.Comma = ';'
|
||||||
|
|
||||||
|
// Read header
|
||||||
|
header, err := reader.Read()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read header: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(header) != 6 {
|
||||||
|
t.Errorf("Expected 6 columns, got %d", len(header))
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedHeader := []string{"Артикул", "Описание", "Категория", "Количество", "Цена за единицу", "Сумма"}
|
||||||
|
for i, col := range expectedHeader {
|
||||||
|
if i < len(header) && header[i] != col {
|
||||||
|
t.Errorf("Column %d: expected %q, got %q", i, col, header[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read item row
|
||||||
|
itemRow, err := reader.Read()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read item row: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if itemRow[0] != "LOT-001" {
|
||||||
|
t.Errorf("Lot name mismatch: expected LOT-001, got %s", itemRow[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
if itemRow[3] != "2" {
|
||||||
|
t.Errorf("Quantity mismatch: expected 2, got %s", itemRow[3])
|
||||||
|
}
|
||||||
|
|
||||||
|
if itemRow[4] != "100,50" {
|
||||||
|
t.Errorf("Unit price mismatch: expected 100,50, got %s", itemRow[4])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToCSV_TotalRow(t *testing.T) {
|
||||||
|
svc := NewExportService(config.ExportConfig{}, nil)
|
||||||
|
|
||||||
|
data := &ExportData{
|
||||||
|
Name: "Test",
|
||||||
|
Items: []ExportItem{
|
||||||
|
{
|
||||||
|
LotName: "LOT-001",
|
||||||
|
Description: "Item 1",
|
||||||
|
Category: "CAT",
|
||||||
|
Quantity: 1,
|
||||||
|
UnitPrice: 100.0,
|
||||||
|
TotalPrice: 100.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
LotName: "LOT-002",
|
||||||
|
Description: "Item 2",
|
||||||
|
Category: "CAT",
|
||||||
|
Quantity: 2,
|
||||||
|
UnitPrice: 50.0,
|
||||||
|
TotalPrice: 100.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Total: 200.0,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := svc.ToCSV(&buf, data); err != nil {
|
||||||
|
t.Fatalf("ToCSV failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
csvBytes := buf.Bytes()
|
||||||
|
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
||||||
|
reader.Comma = ';'
|
||||||
|
|
||||||
|
// Skip header and item rows
|
||||||
|
reader.Read()
|
||||||
|
reader.Read()
|
||||||
|
reader.Read()
|
||||||
|
|
||||||
|
// Read total row
|
||||||
|
totalRow, err := reader.Read()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read total row: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total row should have "ИТОГО:" in position 4 and total value in position 5
|
||||||
|
if totalRow[4] != "ИТОГО:" {
|
||||||
|
t.Errorf("Expected 'ИТОГО:' in column 4, got %q", totalRow[4])
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalRow[5] != "200,00" {
|
||||||
|
t.Errorf("Expected total 200,00, got %s", totalRow[5])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToCSV_CategorySorting(t *testing.T) {
|
||||||
|
// Test category sorting without category repo (items maintain original order)
|
||||||
|
svc := NewExportService(config.ExportConfig{}, nil)
|
||||||
|
|
||||||
|
data := &ExportData{
|
||||||
|
Name: "Test",
|
||||||
|
Items: []ExportItem{
|
||||||
|
{
|
||||||
|
LotName: "LOT-001",
|
||||||
|
Category: "CAT-A",
|
||||||
|
Quantity: 1,
|
||||||
|
UnitPrice: 100.0,
|
||||||
|
TotalPrice: 100.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
LotName: "LOT-002",
|
||||||
|
Category: "CAT-C",
|
||||||
|
Quantity: 1,
|
||||||
|
UnitPrice: 100.0,
|
||||||
|
TotalPrice: 100.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
LotName: "LOT-003",
|
||||||
|
Category: "CAT-B",
|
||||||
|
Quantity: 1,
|
||||||
|
UnitPrice: 100.0,
|
||||||
|
TotalPrice: 100.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Total: 300.0,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := svc.ToCSV(&buf, data); err != nil {
|
||||||
|
t.Fatalf("ToCSV failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
csvBytes := buf.Bytes()
|
||||||
|
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
||||||
|
reader.Comma = ';'
|
||||||
|
|
||||||
|
// Skip header
|
||||||
|
reader.Read()
|
||||||
|
|
||||||
|
// Without category repo, items maintain original order
|
||||||
|
row1, _ := reader.Read()
|
||||||
|
if row1[0] != "LOT-001" {
|
||||||
|
t.Errorf("Expected LOT-001 first, got %s", row1[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
row2, _ := reader.Read()
|
||||||
|
if row2[0] != "LOT-002" {
|
||||||
|
t.Errorf("Expected LOT-002 second, got %s", row2[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
row3, _ := reader.Read()
|
||||||
|
if row3[0] != "LOT-003" {
|
||||||
|
t.Errorf("Expected LOT-003 third, got %s", row3[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToCSV_EmptyData(t *testing.T) {
|
||||||
|
svc := NewExportService(config.ExportConfig{}, nil)
|
||||||
|
|
||||||
|
data := &ExportData{
|
||||||
|
Name: "Test",
|
||||||
|
Items: []ExportItem{},
|
||||||
|
Total: 0.0,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := svc.ToCSV(&buf, data); err != nil {
|
||||||
|
t.Fatalf("ToCSV failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
csvBytes := buf.Bytes()
|
||||||
|
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
||||||
|
reader.Comma = ';'
|
||||||
|
|
||||||
|
// Should have header and total row
|
||||||
|
header, err := reader.Read()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read header: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(header) != 6 {
|
||||||
|
t.Errorf("Expected 6 columns, got %d", len(header))
|
||||||
|
}
|
||||||
|
|
||||||
|
totalRow, err := reader.Read()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read total row: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalRow[4] != "ИТОГО:" {
|
||||||
|
t.Errorf("Expected ИТОГО: in total row, got %s", totalRow[4])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToCSVBytes_BackwardCompat(t *testing.T) {
|
||||||
|
svc := NewExportService(config.ExportConfig{}, nil)
|
||||||
|
|
||||||
|
data := &ExportData{
|
||||||
|
Name: "Test",
|
||||||
|
Items: []ExportItem{
|
||||||
|
{
|
||||||
|
LotName: "LOT-001",
|
||||||
|
Description: "Test Item",
|
||||||
|
Category: "CAT",
|
||||||
|
Quantity: 1,
|
||||||
|
UnitPrice: 100.0,
|
||||||
|
TotalPrice: 100.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Total: 100.0,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
csvBytes, err := svc.ToCSVBytes(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ToCSVBytes failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(csvBytes) < 3 {
|
||||||
|
t.Fatalf("CSV bytes too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify BOM is present
|
||||||
|
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
||||||
|
actualBOM := csvBytes[:3]
|
||||||
|
if bytes.Compare(actualBOM, expectedBOM) != 0 {
|
||||||
|
t.Errorf("UTF-8 BOM mismatch in ToCSVBytes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToCSV_WriterError(t *testing.T) {
|
||||||
|
svc := NewExportService(config.ExportConfig{}, nil)
|
||||||
|
|
||||||
|
data := &ExportData{
|
||||||
|
Name: "Test",
|
||||||
|
Items: []ExportItem{
|
||||||
|
{
|
||||||
|
LotName: "LOT-001",
|
||||||
|
Description: "Test",
|
||||||
|
Category: "CAT",
|
||||||
|
Quantity: 1,
|
||||||
|
UnitPrice: 100.0,
|
||||||
|
TotalPrice: 100.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Total: 100.0,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a failing writer
|
||||||
|
failingWriter := &failingWriter{}
|
||||||
|
|
||||||
|
if err := svc.ToCSV(failingWriter, data); err == nil {
|
||||||
|
t.Errorf("Expected error from failing writer, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// failingWriter always returns an error
|
||||||
|
type failingWriter struct{}
|
||||||
|
|
||||||
|
func (fw *failingWriter) Write(p []byte) (int, error) {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
@@ -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, ¤t, "rollback", version, userID); err != nil {
|
if err := s.enqueueConfigurationPendingChangeTx(tx, ¤t, "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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
319
internal/services/project.go
Normal file
319
internal/services/project.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
124
internal/services/quote_price_levels_test.go
Normal file
124
internal/services/quote_price_levels_test.go
Normal 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
|
||||||
|
}
|
||||||
410
internal/services/sync/readiness.go
Normal file
410
internal/services/sync/readiness.go
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
package sync
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ReadinessReady = "ready"
|
||||||
|
ReadinessBlocked = "blocked"
|
||||||
|
ReadinessUnknown = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrSyncBlockedByReadiness = errors.New("sync blocked by readiness guard")
|
||||||
|
|
||||||
|
type SyncReadiness 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SyncBlockedError struct {
|
||||||
|
Readiness SyncReadiness
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *SyncBlockedError) Error() string {
|
||||||
|
if e == nil {
|
||||||
|
return ErrSyncBlockedByReadiness.Error()
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(e.Readiness.ReasonText) != "" {
|
||||||
|
return e.Readiness.ReasonText
|
||||||
|
}
|
||||||
|
return ErrSyncBlockedByReadiness.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) EnsureReadinessForSync() (*SyncReadiness, error) {
|
||||||
|
readiness, err := s.GetReadiness()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if readiness.Blocked {
|
||||||
|
return readiness, &SyncBlockedError{Readiness: *readiness}
|
||||||
|
}
|
||||||
|
return readiness, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetReadiness() (*SyncReadiness, error) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if !s.isOnline() {
|
||||||
|
return s.blockedReadiness(
|
||||||
|
now,
|
||||||
|
"OFFLINE_UNVERIFIED_SCHEMA",
|
||||||
|
"Синхронизация недоступна: нет соединения с сервером и нельзя проверить миграции локальной БД.",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
mariaDB, err := s.getDB()
|
||||||
|
if err != nil || mariaDB == nil {
|
||||||
|
return s.blockedReadiness(
|
||||||
|
now,
|
||||||
|
"OFFLINE_UNVERIFIED_SCHEMA",
|
||||||
|
"Синхронизация недоступна: нет соединения с сервером и нельзя проверить миграции локальной БД.",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
migrations, err := listActiveClientMigrations(mariaDB)
|
||||||
|
if err != nil {
|
||||||
|
return s.blockedReadiness(
|
||||||
|
now,
|
||||||
|
"REMOTE_MIGRATION_REGISTRY_UNAVAILABLE",
|
||||||
|
"Синхронизация заблокирована: не удалось проверить централизованные миграции локальной БД.",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range migrations {
|
||||||
|
m := migrations[i]
|
||||||
|
if strings.TrimSpace(m.MinAppVersion) != "" {
|
||||||
|
if compareVersions(appmeta.Version(), m.MinAppVersion) < 0 {
|
||||||
|
min := m.MinAppVersion
|
||||||
|
return s.blockedReadiness(
|
||||||
|
now,
|
||||||
|
"MIN_APP_VERSION_REQUIRED",
|
||||||
|
fmt.Sprintf("Требуется обновление приложения до версии %s для безопасной синхронизации.", m.MinAppVersion),
|
||||||
|
&min,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.applyMissingRemoteMigrations(migrations); err != nil {
|
||||||
|
if strings.Contains(strings.ToLower(err.Error()), "checksum") {
|
||||||
|
return s.blockedReadiness(
|
||||||
|
now,
|
||||||
|
"REMOTE_MIGRATION_CHECKSUM_MISMATCH",
|
||||||
|
"Синхронизация заблокирована: контрольная сумма миграции не совпадает.",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return s.blockedReadiness(
|
||||||
|
now,
|
||||||
|
"LOCAL_MIGRATION_APPLY_FAILED",
|
||||||
|
"Синхронизация заблокирована: не удалось применить миграции локальной БД.",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.reportClientSchemaState(mariaDB, now); err != nil {
|
||||||
|
slog.Warn("failed to report client schema state", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ready := &SyncReadiness{Status: ReadinessReady, Blocked: false, LastCheckedAt: &now}
|
||||||
|
if setErr := s.localDB.SetSyncGuardState(ReadinessReady, "", "", nil, &now); setErr != nil {
|
||||||
|
slog.Warn("failed to persist sync guard state", "error", setErr)
|
||||||
|
}
|
||||||
|
return ready, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) blockedReadiness(now time.Time, code, text string, minVersion *string) (*SyncReadiness, error) {
|
||||||
|
readiness := &SyncReadiness{
|
||||||
|
Status: ReadinessBlocked,
|
||||||
|
Blocked: true,
|
||||||
|
ReasonCode: code,
|
||||||
|
ReasonText: text,
|
||||||
|
RequiredMinAppVersion: minVersion,
|
||||||
|
LastCheckedAt: &now,
|
||||||
|
}
|
||||||
|
if err := s.localDB.SetSyncGuardState(ReadinessBlocked, code, text, minVersion, &now); err != nil {
|
||||||
|
slog.Warn("failed to persist blocked sync guard state", "error", err)
|
||||||
|
}
|
||||||
|
return readiness, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) isOnline() bool {
|
||||||
|
if s.directDB != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if s.connMgr == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return s.connMgr.IsOnline()
|
||||||
|
}
|
||||||
|
|
||||||
|
type clientLocalMigration struct {
|
||||||
|
ID string `gorm:"column:id"`
|
||||||
|
Name string `gorm:"column:name"`
|
||||||
|
SQLText string `gorm:"column:sql_text"`
|
||||||
|
Checksum string `gorm:"column:checksum"`
|
||||||
|
MinAppVersion string `gorm:"column:min_app_version"`
|
||||||
|
OrderNo int `gorm:"column:order_no"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func listActiveClientMigrations(db *gorm.DB) ([]clientLocalMigration, error) {
|
||||||
|
if strings.EqualFold(db.Dialector.Name(), "sqlite") {
|
||||||
|
return []clientLocalMigration{}, nil
|
||||||
|
}
|
||||||
|
if err := ensureClientMigrationRegistryTable(db); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := make([]clientLocalMigration, 0)
|
||||||
|
if err := db.Raw(`
|
||||||
|
SELECT id, name, sql_text, checksum, COALESCE(min_app_version, '') AS min_app_version, order_no, created_at
|
||||||
|
FROM qt_client_local_migrations
|
||||||
|
WHERE is_active = 1
|
||||||
|
ORDER BY order_no ASC, created_at ASC, id ASC
|
||||||
|
`).Scan(&rows).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("load client local migrations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureClientMigrationRegistryTable(db *gorm.DB) error {
|
||||||
|
// Check if table exists instead of trying to create (avoids permission issues)
|
||||||
|
if !tableExists(db, "qt_client_local_migrations") {
|
||||||
|
if err := db.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS qt_client_local_migrations (
|
||||||
|
id VARCHAR(128) NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
sql_text LONGTEXT NOT NULL,
|
||||||
|
checksum VARCHAR(128) NOT NULL,
|
||||||
|
min_app_version VARCHAR(64) NULL,
|
||||||
|
order_no INT NOT NULL DEFAULT 0,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
INDEX idx_qt_client_local_migrations_active_order (is_active, order_no, created_at)
|
||||||
|
)
|
||||||
|
`).Error; err != nil {
|
||||||
|
return fmt.Errorf("create qt_client_local_migrations table: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tableExists(db, "qt_client_schema_state") {
|
||||||
|
if err := db.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS qt_client_schema_state (
|
||||||
|
username VARCHAR(100) NOT NULL,
|
||||||
|
last_applied_migration_id VARCHAR(128) NULL,
|
||||||
|
app_version VARCHAR(64) NULL,
|
||||||
|
last_checked_at DATETIME NOT NULL,
|
||||||
|
updated_at DATETIME NOT NULL,
|
||||||
|
PRIMARY KEY (username),
|
||||||
|
INDEX idx_qt_client_schema_state_checked (last_checked_at)
|
||||||
|
)
|
||||||
|
`).Error; err != nil {
|
||||||
|
return fmt.Errorf("create qt_client_schema_state table: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableExists(db *gorm.DB, tableName string) bool {
|
||||||
|
var count int64
|
||||||
|
// For MariaDB/MySQL, check information_schema
|
||||||
|
if err := db.Raw(`
|
||||||
|
SELECT COUNT(*) FROM information_schema.TABLES
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?
|
||||||
|
`, tableName).Scan(&count).Error; err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return count > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) applyMissingRemoteMigrations(migrations []clientLocalMigration) error {
|
||||||
|
for i := range migrations {
|
||||||
|
m := migrations[i]
|
||||||
|
computedChecksum := digestSQL(m.SQLText)
|
||||||
|
checksum := strings.TrimSpace(m.Checksum)
|
||||||
|
if checksum == "" {
|
||||||
|
checksum = computedChecksum
|
||||||
|
} else if !strings.EqualFold(checksum, computedChecksum) {
|
||||||
|
return fmt.Errorf("checksum mismatch for migration %s", m.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
applied, err := s.localDB.GetRemoteMigrationApplied(m.ID)
|
||||||
|
if err == nil {
|
||||||
|
if strings.TrimSpace(applied.Checksum) != checksum {
|
||||||
|
return fmt.Errorf("checksum mismatch for migration %s", m.ID)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return fmt.Errorf("check local applied migration %s: %w", m.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(m.SQLText) == "" {
|
||||||
|
if err := s.localDB.UpsertRemoteMigrationApplied(m.ID, checksum, appmeta.Version(), time.Now().UTC()); err != nil {
|
||||||
|
return fmt.Errorf("mark empty migration %s as applied: %w", m.ID, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
statements := splitSQLStatementsLite(m.SQLText)
|
||||||
|
if err := s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
||||||
|
for _, stmt := range statements {
|
||||||
|
if err := tx.Exec(stmt).Error; err != nil {
|
||||||
|
return fmt.Errorf("apply migration %s statement %q: %w", m.ID, stmt, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.localDB.UpsertRemoteMigrationApplied(m.ID, checksum, appmeta.Version(), time.Now().UTC()); err != nil {
|
||||||
|
return fmt.Errorf("record applied migration %s: %w", m.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitSQLStatementsLite(script string) []string {
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(script))
|
||||||
|
scanner.Buffer(make([]byte, 1024), 1024*1024)
|
||||||
|
|
||||||
|
lines := make([]string, 0, 64)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || 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 digestSQL(sqlText string) string {
|
||||||
|
hash := sha256.Sum256([]byte(sqlText))
|
||||||
|
return hex.EncodeToString(hash[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareVersions(left, right string) int {
|
||||||
|
leftParts := normalizeVersionParts(left)
|
||||||
|
rightParts := normalizeVersionParts(right)
|
||||||
|
maxLen := len(leftParts)
|
||||||
|
if len(rightParts) > maxLen {
|
||||||
|
maxLen = len(rightParts)
|
||||||
|
}
|
||||||
|
for i := 0; i < maxLen; i++ {
|
||||||
|
lv := 0
|
||||||
|
rv := 0
|
||||||
|
if i < len(leftParts) {
|
||||||
|
lv = leftParts[i]
|
||||||
|
}
|
||||||
|
if i < len(rightParts) {
|
||||||
|
rv = rightParts[i]
|
||||||
|
}
|
||||||
|
if lv < rv {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if lv > rv {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time) error {
|
||||||
|
if strings.EqualFold(mariaDB.Dialector.Name(), "sqlite") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
username := strings.TrimSpace(s.localDB.GetDBUser())
|
||||||
|
if username == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lastMigrationID := ""
|
||||||
|
if id, err := s.localDB.GetLatestAppliedRemoteMigrationID(); err == nil {
|
||||||
|
lastMigrationID = id
|
||||||
|
}
|
||||||
|
return mariaDB.Exec(`
|
||||||
|
INSERT INTO qt_client_schema_state (username, last_applied_migration_id, app_version, last_checked_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
last_applied_migration_id = VALUES(last_applied_migration_id),
|
||||||
|
app_version = VALUES(app_version),
|
||||||
|
last_checked_at = VALUES(last_checked_at),
|
||||||
|
updated_at = VALUES(updated_at)
|
||||||
|
`, username, lastMigrationID, appmeta.Version(), checkedAt, checkedAt).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeVersionParts(v string) []int {
|
||||||
|
trimmed := strings.TrimSpace(v)
|
||||||
|
trimmed = strings.TrimPrefix(trimmed, "v")
|
||||||
|
chunks := strings.Split(trimmed, ".")
|
||||||
|
parts := make([]int, 0, len(chunks))
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
clean := strings.TrimSpace(chunk)
|
||||||
|
if clean == "" {
|
||||||
|
parts = append(parts, 0)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for i := 0; i < len(clean); i++ {
|
||||||
|
if clean[i] < '0' || clean[i] > '9' {
|
||||||
|
clean = clean[:i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if clean != "" {
|
||||||
|
if parsed, err := strconv.Atoi(clean); err == nil {
|
||||||
|
n = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts = append(parts, n)
|
||||||
|
}
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
func toReadinessFromState(state *localdb.LocalSyncGuardState) *SyncReadiness {
|
||||||
|
if state == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
blocked := state.Status == ReadinessBlocked
|
||||||
|
return &SyncReadiness{
|
||||||
|
Status: state.Status,
|
||||||
|
Blocked: blocked,
|
||||||
|
ReasonCode: state.ReasonCode,
|
||||||
|
ReasonText: state.ReasonText,
|
||||||
|
RequiredMinAppVersion: state.RequiredMinAppVersion,
|
||||||
|
LastCheckedAt: state.LastCheckedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -220,18 +340,22 @@ func (s *Service) SyncPricelists() (int, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("getting active server pricelists: %w", err)
|
return 0, fmt.Errorf("getting active server pricelists: %w", err)
|
||||||
}
|
}
|
||||||
|
serverPricelistIDs := make([]uint, 0, len(serverPricelists))
|
||||||
|
for i := range serverPricelists {
|
||||||
|
serverPricelistIDs = append(serverPricelistIDs, serverPricelists[i].ID)
|
||||||
|
}
|
||||||
|
|
||||||
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 +363,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 +385,23 @@ 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
|
removed, err := s.localDB.DeleteUnusedLocalPricelistsMissingOnServer(serverPricelistIDs)
|
||||||
if latestLocalID > 0 {
|
if err != nil {
|
||||||
updated, err := s.localDB.UpdateComponentPricesFromPricelist(latestLocalID)
|
slog.Warn("failed to cleanup stale local pricelists", "error", err)
|
||||||
|
} else if removed > 0 {
|
||||||
|
slog.Info("deleted stale local pricelists", "deleted", removed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update component prices from latest estimate pricelist only.
|
||||||
|
if latestEstimateLocalID > 0 {
|
||||||
|
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 +411,178 @@ 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 {
|
||||||
|
// Check if table exists instead of trying to create (avoids permission issues)
|
||||||
|
if !tableExists(db, "qt_pricelist_sync_status") {
|
||||||
|
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 fmt.Errorf("create qt_pricelist_sync_status table: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backward compatibility for environments where table was created without app_version.
|
||||||
|
// Only try to add column if table exists.
|
||||||
|
if tableExists(db, "qt_pricelist_sync_status") {
|
||||||
|
if err := db.Exec(`
|
||||||
|
ALTER TABLE qt_pricelist_sync_status
|
||||||
|
ADD COLUMN IF NOT EXISTS app_version VARCHAR(64) NULL
|
||||||
|
`).Error; err != nil {
|
||||||
|
// Log but don't fail if alter fails (column might already exist)
|
||||||
|
slog.Debug("failed to add app_version column", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// 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 +599,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 +616,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 +700,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 +724,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 +753,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 +762,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 +871,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 +881,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 +932,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 +942,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 +958,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 +1030,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 +1147,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 +1170,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 +1207,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 +1265,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 +1289,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()
|
||||||
|
}
|
||||||
|
|||||||
25
internal/services/sync/service_order_test.go
Normal file
25
internal/services/sync/service_order_test.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package sync
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPrioritizeProjectChanges(t *testing.T) {
|
||||||
|
changes := []localdb.PendingChange{
|
||||||
|
{ID: 1, EntityType: "configuration"},
|
||||||
|
{ID: 2, EntityType: "project"},
|
||||||
|
{ID: 3, EntityType: "configuration"},
|
||||||
|
{ID: 4, EntityType: "project"},
|
||||||
|
}
|
||||||
|
|
||||||
|
sorted := prioritizeProjectChanges(changes)
|
||||||
|
if len(sorted) != 4 {
|
||||||
|
t.Fatalf("unexpected sorted length: %d", len(sorted))
|
||||||
|
}
|
||||||
|
|
||||||
|
if sorted[0].EntityType != "project" || sorted[1].EntityType != "project" {
|
||||||
|
t.Fatalf("expected project changes first, got order: %s, %s", sorted[0].EntityType, sorted[1].EntityType)
|
||||||
|
}
|
||||||
|
}
|
||||||
85
internal/services/sync/service_pricelist_cleanup_test.go
Normal file
85
internal/services/sync/service_pricelist_cleanup_test.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package sync_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
|
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSyncPricelistsDeletesMissingUnusedLocalPricelists(t *testing.T) {
|
||||||
|
local := newLocalDBForSyncTest(t)
|
||||||
|
serverDB := newServerDBForSyncTest(t)
|
||||||
|
if err := serverDB.AutoMigrate(&models.Pricelist{}, &models.PricelistItem{}); err != nil {
|
||||||
|
t.Fatalf("migrate server pricelist tables: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
serverPL := models.Pricelist{
|
||||||
|
Source: "estimate",
|
||||||
|
Version: "2026-01-01-001",
|
||||||
|
Notification: "server",
|
||||||
|
CreatedBy: "tester",
|
||||||
|
IsActive: true,
|
||||||
|
CreatedAt: time.Now().Add(-1 * time.Hour),
|
||||||
|
}
|
||||||
|
if err := serverDB.Create(&serverPL).Error; err != nil {
|
||||||
|
t.Fatalf("create server pricelist: %v", err)
|
||||||
|
}
|
||||||
|
if err := serverDB.Create(&models.PricelistItem{PricelistID: serverPL.ID, LotName: "CPU_A", Price: 10}).Error; err != nil {
|
||||||
|
t.Fatalf("create server pricelist item: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||||
|
ServerID: 9991,
|
||||||
|
Source: "estimate",
|
||||||
|
Version: "old-unused",
|
||||||
|
Name: "old-unused",
|
||||||
|
CreatedAt: time.Now().Add(-2 * time.Hour),
|
||||||
|
SyncedAt: time.Now().Add(-2 * time.Hour),
|
||||||
|
IsUsed: false,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed local missing pricelist: %v", err)
|
||||||
|
}
|
||||||
|
missingUsed := &localdb.LocalPricelist{
|
||||||
|
ServerID: 9992,
|
||||||
|
Source: "estimate",
|
||||||
|
Version: "old-used",
|
||||||
|
Name: "old-used",
|
||||||
|
CreatedAt: time.Now().Add(-2 * time.Hour),
|
||||||
|
SyncedAt: time.Now().Add(-2 * time.Hour),
|
||||||
|
IsUsed: false,
|
||||||
|
}
|
||||||
|
if err := local.SaveLocalPricelist(missingUsed); err != nil {
|
||||||
|
t.Fatalf("seed local referenced pricelist: %v", err)
|
||||||
|
}
|
||||||
|
if err := local.SaveConfiguration(&localdb.LocalConfiguration{
|
||||||
|
UUID: "cfg-1",
|
||||||
|
OriginalUsername: "tester",
|
||||||
|
Name: "cfg",
|
||||||
|
Items: localdb.LocalConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1}},
|
||||||
|
IsActive: true,
|
||||||
|
PricelistID: &missingUsed.ServerID,
|
||||||
|
SyncStatus: "synced",
|
||||||
|
CreatedAt: time.Now().Add(-30 * time.Minute),
|
||||||
|
UpdatedAt: time.Now().Add(-30 * time.Minute),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed local configuration with pricelist ref: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := syncsvc.NewServiceWithDB(serverDB, local)
|
||||||
|
if _, err := svc.SyncPricelists(); err != nil {
|
||||||
|
t.Fatalf("sync pricelists: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := local.GetLocalPricelistByServerID(9991); err == nil {
|
||||||
|
t.Fatalf("expected unused missing local pricelist to be deleted")
|
||||||
|
}
|
||||||
|
if _, err := local.GetLocalPricelistByServerID(9992); err != nil {
|
||||||
|
t.Fatalf("expected local pricelist referenced by active config to stay: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := local.GetLocalPricelistByServerID(serverPL.ID); err != nil {
|
||||||
|
t.Fatalf("expected server pricelist to be synced locally: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
378
internal/services/sync/service_projects_push_test.go
Normal file
378
internal/services/sync/service_projects_push_test.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
25
migrations/007_detach_configurations_from_qt_users.sql
Normal file
25
migrations/007_detach_configurations_from_qt_users.sql
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
-- Detach qt_configurations from qt_users (ownership is owner_username text)
|
||||||
|
-- Safe for MySQL 8+/MariaDB 10.2+ via INFORMATION_SCHEMA checks.
|
||||||
|
|
||||||
|
SET @fk_exists := (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM information_schema.TABLE_CONSTRAINTS
|
||||||
|
WHERE CONSTRAINT_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'qt_configurations'
|
||||||
|
AND CONSTRAINT_NAME = 'fk_qt_configurations_user'
|
||||||
|
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
|
||||||
|
);
|
||||||
|
|
||||||
|
SET @drop_fk_sql := IF(
|
||||||
|
@fk_exists > 0,
|
||||||
|
'ALTER TABLE qt_configurations DROP FOREIGN KEY fk_qt_configurations_user',
|
||||||
|
'SELECT ''fk_qt_configurations_user not found, skip'' '
|
||||||
|
);
|
||||||
|
PREPARE stmt_drop_fk FROM @drop_fk_sql;
|
||||||
|
EXECUTE stmt_drop_fk;
|
||||||
|
DEALLOCATE PREPARE stmt_drop_fk;
|
||||||
|
|
||||||
|
-- user_id becomes optional legacy column (can stay NULL)
|
||||||
|
ALTER TABLE qt_configurations
|
||||||
|
MODIFY COLUMN user_id BIGINT UNSIGNED NULL;
|
||||||
|
|
||||||
4
migrations/008_add_app_version_to_configurations.sql
Normal file
4
migrations/008_add_app_version_to_configurations.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
-- Track application version used for configuration writes (create/update via sync)
|
||||||
|
ALTER TABLE qt_configurations
|
||||||
|
ADD COLUMN app_version VARCHAR(64) NULL DEFAULT NULL AFTER owner_username;
|
||||||
|
|
||||||
45
migrations/009_add_projects.sql
Normal file
45
migrations/009_add_projects.sql
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
-- Add projects and attach configurations to projects
|
||||||
|
|
||||||
|
CREATE TABLE qt_projects (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
uuid CHAR(36) NOT NULL UNIQUE,
|
||||||
|
owner_username VARCHAR(100) NOT NULL,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
is_system BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_qt_projects_owner_username (owner_username),
|
||||||
|
INDEX idx_qt_projects_is_active (is_active),
|
||||||
|
INDEX idx_qt_projects_is_system (is_system)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE qt_configurations
|
||||||
|
ADD COLUMN project_uuid CHAR(36) NULL AFTER app_version,
|
||||||
|
ADD INDEX idx_qt_configurations_project_uuid (project_uuid),
|
||||||
|
ADD CONSTRAINT fk_qt_configurations_project_uuid
|
||||||
|
FOREIGN KEY (project_uuid) REFERENCES qt_projects(uuid)
|
||||||
|
ON UPDATE CASCADE
|
||||||
|
ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- One system project per owner: "Без проекта"
|
||||||
|
INSERT INTO qt_projects (uuid, owner_username, name, is_active, is_system, created_at, updated_at)
|
||||||
|
SELECT UUID(), owners.owner_username, 'Без проекта', TRUE, TRUE, NOW(), NOW()
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT owner_username
|
||||||
|
FROM qt_configurations
|
||||||
|
) AS owners
|
||||||
|
LEFT JOIN qt_projects p
|
||||||
|
ON p.owner_username = owners.owner_username
|
||||||
|
AND p.name = 'Без проекта'
|
||||||
|
AND p.is_system = TRUE
|
||||||
|
WHERE p.id IS NULL;
|
||||||
|
|
||||||
|
-- Attach all existing configurations without project to the system project
|
||||||
|
UPDATE qt_configurations c
|
||||||
|
JOIN qt_projects p
|
||||||
|
ON p.owner_username = c.owner_username
|
||||||
|
AND p.name = 'Без проекта'
|
||||||
|
AND p.is_system = TRUE
|
||||||
|
SET c.project_uuid = p.uuid
|
||||||
|
WHERE c.project_uuid IS NULL OR c.project_uuid = '';
|
||||||
8
migrations/010_add_pricelist_sync_status.sql
Normal file
8
migrations/010_add_pricelist_sync_status.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS qt_pricelist_sync_status (
|
||||||
|
username VARCHAR(100) NOT NULL,
|
||||||
|
last_sync_at DATETIME NOT NULL,
|
||||||
|
updated_at DATETIME NOT NULL,
|
||||||
|
app_version VARCHAR(64) NULL,
|
||||||
|
PRIMARY KEY (username),
|
||||||
|
INDEX idx_qt_pricelist_sync_status_last_sync (last_sync_at)
|
||||||
|
);
|
||||||
37
migrations/010_add_pricelist_to_configurations.sql
Normal file
37
migrations/010_add_pricelist_to_configurations.sql
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
-- Add pricelist binding to configurations
|
||||||
|
ALTER TABLE qt_configurations
|
||||||
|
ADD COLUMN pricelist_id BIGINT UNSIGNED NULL AFTER server_count;
|
||||||
|
|
||||||
|
ALTER TABLE qt_configurations
|
||||||
|
ADD INDEX idx_qt_configurations_pricelist_id (pricelist_id),
|
||||||
|
ADD CONSTRAINT fk_qt_configurations_pricelist_id
|
||||||
|
FOREIGN KEY (pricelist_id)
|
||||||
|
REFERENCES qt_pricelists(id)
|
||||||
|
ON DELETE RESTRICT
|
||||||
|
ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Backfill existing configurations to latest active pricelist
|
||||||
|
SET @latest_active_pricelist_id := (
|
||||||
|
SELECT id
|
||||||
|
FROM qt_pricelists
|
||||||
|
WHERE is_active = 1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE qt_configurations
|
||||||
|
SET pricelist_id = @latest_active_pricelist_id
|
||||||
|
WHERE pricelist_id IS NULL
|
||||||
|
AND @latest_active_pricelist_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Recalculate usage_count from configuration bindings
|
||||||
|
UPDATE qt_pricelists SET usage_count = 0;
|
||||||
|
|
||||||
|
UPDATE qt_pricelists pl
|
||||||
|
JOIN (
|
||||||
|
SELECT pricelist_id, COUNT(*) AS cnt
|
||||||
|
FROM qt_configurations
|
||||||
|
WHERE pricelist_id IS NOT NULL
|
||||||
|
GROUP BY pricelist_id
|
||||||
|
) cfg ON cfg.pricelist_id = pl.id
|
||||||
|
SET pl.usage_count = cfg.cnt;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE qt_pricelist_sync_status
|
||||||
|
ADD COLUMN IF NOT EXISTS app_version VARCHAR(64) NULL;
|
||||||
7
migrations/012_add_project_tracker_url.sql
Normal file
7
migrations/012_add_project_tracker_url.sql
Normal 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, '')) <> '';
|
||||||
15
migrations/013_add_pricelist_source.sql
Normal file
15
migrations/013_add_pricelist_source.sql
Normal 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);
|
||||||
14
migrations/014_add_stock_log.sql
Normal file
14
migrations/014_add_stock_log.sql
Normal 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)
|
||||||
|
);
|
||||||
7
migrations/015_add_lot_partnumbers.sql
Normal file
7
migrations/015_add_lot_partnumbers.sql
Normal 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)
|
||||||
|
);
|
||||||
25
migrations/016_add_price_level_pricelist_bindings.sql
Normal file
25
migrations/016_add_price_level_pricelist_bindings.sql
Normal 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;
|
||||||
25
migrations/017_update_lot_partnumbers_for_placeholders.sql
Normal file
25
migrations/017_update_lot_partnumbers_for_placeholders.sql
Normal 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;
|
||||||
10
migrations/018_add_stock_ignore_rules.sql
Normal file
10
migrations/018_add_stock_ignore_rules.sql
Normal 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)
|
||||||
|
);
|
||||||
2
migrations/019_rename_stock_log_lot_to_partnumber.sql
Normal file
2
migrations/019_rename_stock_log_lot_to_partnumber.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE stock_log
|
||||||
|
CHANGE COLUMN lot partnumber VARCHAR(255) NOT NULL;
|
||||||
3
migrations/020_add_only_in_stock_to_configurations.sql
Normal file
3
migrations/020_add_only_in_stock_to_configurations.sql
Normal 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;
|
||||||
19
migrations/021_add_pricelist_items_pricelist_lot_index.sql
Normal file
19
migrations/021_add_pricelist_items_pricelist_lot_index.sql
Normal 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;
|
||||||
56
scripts/check-secrets.sh
Executable file
56
scripts/check-secrets.sh
Executable file
@@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if ! git rev-parse --git-dir >/dev/null 2>&1; then
|
||||||
|
echo "Not inside a git repository."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v rg >/dev/null 2>&1; then
|
||||||
|
echo "ripgrep (rg) is required for secret scanning."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
staged_files=()
|
||||||
|
while IFS= read -r file; do
|
||||||
|
staged_files+=("$file")
|
||||||
|
done < <(git diff --cached --name-only --diff-filter=ACMRTUXB)
|
||||||
|
|
||||||
|
if [ "${#staged_files[@]}" -eq 0 ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
secret_pattern='AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{35}|-----BEGIN (RSA|OPENSSH|EC|DSA|PRIVATE) KEY-----|(?i)(password|passwd|pwd|secret|token|api[_-]?key|jwt_secret)\s*[:=]\s*["'"'"'][^"'"'"'\s]{8,}["'"'"']'
|
||||||
|
allow_pattern='CHANGE_ME|REDACTED|PLACEHOLDER|EXAMPLE|example|<[^>]+>'
|
||||||
|
|
||||||
|
found=0
|
||||||
|
|
||||||
|
for file in "${staged_files[@]}"; do
|
||||||
|
case "$file" in
|
||||||
|
dist/*|*.png|*.jpg|*.jpeg|*.gif|*.webp|*.pdf|*.zip|*.gz|*.exe|*.dll|*.so|*.dylib)
|
||||||
|
continue
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if ! content="$(git show ":$file" 2>/dev/null)"; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
hits="$(printf '%s' "$content" | rg -n --no-heading -e "$secret_pattern" || true)"
|
||||||
|
if [ -n "$hits" ]; then
|
||||||
|
filtered="$(printf '%s\n' "$hits" | rg -v -e "$allow_pattern" || true)"
|
||||||
|
if [ -n "$filtered" ]; then
|
||||||
|
echo "Potential secret found in staged file: $file"
|
||||||
|
printf '%s\n' "$filtered"
|
||||||
|
found=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$found" -ne 0 ]; then
|
||||||
|
echo
|
||||||
|
echo "Commit blocked: remove or redact secrets before committing."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -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
|
||||||
|
|||||||
78
todo.md
Normal file
78
todo.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# QuoteForge — План очистки (удаление admin pricing)
|
||||||
|
|
||||||
|
Цель: убрать всё, что связано с администрированием цен, складскими справками, алертами.
|
||||||
|
Оставить: конфигуратор, проекты, read-only просмотр прайслистов, sync, offline-first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Удалить файлы
|
||||||
|
|
||||||
|
- [x] `internal/handlers/pricing.go` (40.6KB) — весь admin pricing UI
|
||||||
|
- [x] `internal/services/pricing/` — весь пакет расчёта цен
|
||||||
|
- [x] `internal/services/pricelist/` — весь пакет управления прайслистами
|
||||||
|
- [x] `internal/services/stock_import.go` — импорт складских справок
|
||||||
|
- [x] `internal/services/alerts/` — весь пакет алертов
|
||||||
|
- [x] `internal/warehouse/` — алгоритмы расчёта цен по складу
|
||||||
|
- [x] `web/templates/admin_pricing.html` (109KB) — страница admin pricing
|
||||||
|
- [x] `cmd/cron/` — cron jobs (cleanup-pricelists, update-prices, update-popularity)
|
||||||
|
- [x] `cmd/importer/` — утилита импорта данных
|
||||||
|
|
||||||
|
## 2. Упростить `internal/handlers/pricelist.go` (read-only)
|
||||||
|
|
||||||
|
Read-only методы (List, Get, GetItems, GetLotNames, GetLatest) уже работают
|
||||||
|
только через `h.localDB` (SQLite) без `pricelist.Service`.
|
||||||
|
|
||||||
|
- [x] Убрать поле `service *pricelist.Service` из структуры `PricelistHandler`
|
||||||
|
- [x] Изменить конструктор: `NewPricelistHandler(localDB *localdb.LocalDB)`
|
||||||
|
- [x] Удалить write-методы: `Create()`, `CreateWithProgress()`, `Delete()`, `SetActive()`, `CanWrite()`
|
||||||
|
- [x] Удалить метод `refreshLocalPricelistCacheFromServer()` (зависит от service)
|
||||||
|
- [x] Удалить import `pricelist` пакета
|
||||||
|
- [x] Оставить: `List()`, `Get()`, `GetItems()`, `GetLotNames()`, `GetLatest()`
|
||||||
|
|
||||||
|
## 3. Упростить `cmd/qfs/main.go`
|
||||||
|
|
||||||
|
- [x] Удалить создание сервисов: `pricingService`, `alertService`, `pricelistService`, `stockImportService`
|
||||||
|
- [x] Удалить хэндлер: `pricingHandler`
|
||||||
|
- [x] Изменить создание `pricelistHandler`: `NewPricelistHandler(local)` (без service)
|
||||||
|
- [x] Удалить repositories: `priceRepo`, `alertRepo` (statsRepo оставить — nil-safe)
|
||||||
|
- [x] Удалить все routes `/api/admin/pricing/*` (строки ~1407-1430)
|
||||||
|
- [x] Из `/api/pricelists/*` оставить только read-only:
|
||||||
|
- `GET ""` (List), `GET "/latest"`, `GET "/:id"`, `GET "/:id/items"`, `GET "/:id/lots"`
|
||||||
|
- [x] Удалить write routes: `POST ""`, `POST "/create-with-progress"`, `PATCH "/:id/active"`, `DELETE "/:id"`, `GET "/can-write"`
|
||||||
|
- [x] Удалить web page `/admin/pricing`
|
||||||
|
- [x] Исправить `/pricelists` — вместо redirect на admin/pricing сделать страницу
|
||||||
|
- [x] В `QuoteService` конструкторе: передавать `nil` для `pricingService`
|
||||||
|
- [x] Удалить imports: `pricing`, `pricelist`, `alerts` пакеты
|
||||||
|
|
||||||
|
## 4. Упростить `handlers/web.go`
|
||||||
|
|
||||||
|
- [x] Удалить из `simplePages`: `admin_pricing.html`
|
||||||
|
- [x] Удалить метод: `AdminPricing()`
|
||||||
|
- [x] Оставить все остальные методы включая `Pricelists()` и `PricelistDetail()`
|
||||||
|
|
||||||
|
## 5. Упростить `base.html` (навигация)
|
||||||
|
|
||||||
|
- [x] Убрать ссылку "Администратор цен"
|
||||||
|
- [x] Добавить ссылку "Прайслисты" (на `/pricelists`)
|
||||||
|
- [x] Оставить: "Мои проекты", "Прайслисты", sync indicator
|
||||||
|
|
||||||
|
## 6. Sync — оставить полностью
|
||||||
|
|
||||||
|
- Background worker: pull компоненты + прайслисты, push конфигурации
|
||||||
|
- Все `/api/sync/*` endpoints остаются
|
||||||
|
- Это ядро offline-first архитектуры
|
||||||
|
|
||||||
|
## 7. Верификация
|
||||||
|
|
||||||
|
- [x] `go build ./cmd/qfs` — компилируется
|
||||||
|
- [x] `go vet ./...` — без ошибок
|
||||||
|
- [ ] Запуск → `/configs` работает
|
||||||
|
- [ ] `/pricelists` — read-only список работает
|
||||||
|
- [ ] `/pricelists/:id` — детали работают
|
||||||
|
- [ ] Sync с сервером работает
|
||||||
|
- [ ] Нет ссылок на admin pricing в UI
|
||||||
|
|
||||||
|
## 8. Обновить CLAUDE.md
|
||||||
|
- [x] Убрать разделы про admin pricing, stock import, alerts, cron
|
||||||
|
- [x] Обновить API endpoints список
|
||||||
|
- [x] Обновить описание приложения
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
@@ -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,51 @@ 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 {
|
||||||
|
// Use /api/projects/all to get all projects without pagination
|
||||||
|
const resp = await fetch('/api/projects/all');
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const data = await resp.json();
|
||||||
|
// data is now a simple array of {uuid, name} objects
|
||||||
|
const allProjects = Array.isArray(data) ? data : (data.projects || []);
|
||||||
|
|
||||||
|
// For compatibility with rest of code, populate projectsCache but mainly use projectNameByUUID
|
||||||
|
projectsCache = allProjects;
|
||||||
|
|
||||||
|
allProjects.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
@@ -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>
|
||||||
|
|||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
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>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user