Compare commits
8 Commits
stable
...
4a86f7b7ba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a86f7b7ba | ||
|
|
955467fbea | ||
|
|
9ddffe48e9 | ||
|
|
4732605925 | ||
|
|
d318a7f462 | ||
|
|
1bec110d91 | ||
|
|
6392e4b4a9 | ||
|
|
8f7defdb8a |
@@ -1,5 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
repo_root="$(git rev-parse --show-toplevel)"
|
|
||||||
"$repo_root/scripts/check-secrets.sh"
|
|
||||||
36
.gitignore
vendored
36
.gitignore
vendored
@@ -1,16 +1,5 @@
|
|||||||
# 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
|
||||||
@@ -23,30 +12,10 @@ secrets.yml
|
|||||||
/importer
|
/importer
|
||||||
/cron
|
/cron
|
||||||
/bin/
|
/bin/
|
||||||
qfs
|
|
||||||
|
|
||||||
# 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
|
||||||
@@ -75,7 +44,4 @@ Network Trash Folder
|
|||||||
Temporary Items
|
Temporary Items
|
||||||
.apdisk
|
.apdisk
|
||||||
|
|
||||||
# Release artifacts (binaries, archives, checksums), but DO track releases/memory/ for changelog
|
releases/
|
||||||
releases/*
|
|
||||||
!releases/memory/
|
|
||||||
!releases/memory/**
|
|
||||||
|
|||||||
167
CLAUDE.md
167
CLAUDE.md
@@ -1,24 +1,163 @@
|
|||||||
# QuoteForge - Claude Code Instructions
|
# QuoteForge - Claude Code Instructions
|
||||||
|
|
||||||
## Bible
|
## Overview
|
||||||
|
Корпоративный конфигуратор серверов и формирование КП. MariaDB (RFQ_LOG) + SQLite для оффлайн.
|
||||||
|
|
||||||
The **[bible/](bible/README.md)** is the single source of truth for this project's architecture, schemas, patterns, and rules. Read it before making any changes.
|
## Development Phases
|
||||||
|
|
||||||
**Rules:**
|
### Phase 1: Pricelists in MariaDB ✅ DONE
|
||||||
- Every architectural decision must be recorded in `bible/` in the same commit as the code.
|
### Phase 2: Local SQLite Database ✅ DONE
|
||||||
- Bible files are written and updated in **English only**.
|
|
||||||
- Before working on the codebase, check `releases/memory/` for the latest release notes.
|
|
||||||
|
|
||||||
## Quick Reference
|
### Phase 2.5: Full Offline Mode 🔶 IN PROGRESS
|
||||||
|
**Local-first architecture:** приложение ВСЕГДА работает с SQLite, MariaDB только для синхронизации.
|
||||||
|
|
||||||
|
**Принцип работы:**
|
||||||
|
- ВСЕ операции (CRUD) выполняются в SQLite
|
||||||
|
- При создании конфигурации:
|
||||||
|
1. Если online → проверить новые прайслисты на сервере → скачать если есть
|
||||||
|
2. Далее работаем с local_pricelists (и online, и offline одинаково)
|
||||||
|
- Background sync: push pending_changes → pull updates
|
||||||
|
|
||||||
|
**DONE:**
|
||||||
|
- ✅ Sync queue table (pending_changes) - `internal/localdb/models.go`
|
||||||
|
- ✅ Model converters: MariaDB ↔ SQLite - `internal/localdb/converters.go`
|
||||||
|
- ✅ LocalConfigurationService: все CRUD через SQLite - `internal/services/local_configuration.go`
|
||||||
|
- ✅ Pre-create pricelist check: `SyncPricelistsIfNeeded()` - `internal/services/sync/service.go`
|
||||||
|
- ✅ Push pending changes: `PushPendingChanges()` - sync service + handlers
|
||||||
|
- ✅ Sync API endpoints: `/api/sync/push`, `/pending/count`, `/pending`
|
||||||
|
- ✅ Integrate LocalConfigurationService in main.go (replace ConfigurationService)
|
||||||
|
- ✅ Add routes for new sync endpoints (`/api/sync/push`, `/pending/count`, `/pending`)
|
||||||
|
- ✅ ConfigurationGetter interface for handler compatibility
|
||||||
|
- ✅ Background sync worker: auto-sync every 5min (push + pull) - `internal/services/sync/worker.go`
|
||||||
|
- ✅ UI: sync status indicator (pending badge + sync button + offline/online dot) - `web/templates/partials/sync_status.html`
|
||||||
|
- ✅ RefreshPrices for local mode:
|
||||||
|
- `RefreshPrices()` / `RefreshPricesNoAuth()` в `local_configuration.go`
|
||||||
|
- Берёт цены из `local_components.current_price`
|
||||||
|
- Graceful degradation при отсутствии компонента
|
||||||
|
- Добавлено поле `price_updated_at` в `LocalConfiguration` (models.go:72)
|
||||||
|
- Обновлены converters для PriceUpdatedAt
|
||||||
|
- UI кнопка "Пересчитать цену" работает offline/online
|
||||||
|
- ✅ Fixed sync bugs:
|
||||||
|
- Duplicate entry error при update конфигураций (`sync/service.go:334-365`)
|
||||||
|
- pushConfigurationUpdate теперь проверяет наличие server_id перед update
|
||||||
|
- Если нет ID → получает из LocalConfiguration.ServerID или ищет на сервере
|
||||||
|
- Fixed setup.go: `settings.Password` → `settings.PasswordEncrypted`
|
||||||
|
|
||||||
|
**TODO:**
|
||||||
|
- ❌ Conflict resolution (Phase 4, last-write-wins default)
|
||||||
|
|
||||||
|
### UI Improvements ✅ MOSTLY DONE
|
||||||
|
|
||||||
|
**1. Sync UI + pricelist badge: ✅ DONE**
|
||||||
|
- ✅ `sync_status.html`: SVG иконки Online/Offline (кликабельные → открывают модал)
|
||||||
|
- ✅ Кнопка sync → иконка circular arrows (только full sync)
|
||||||
|
- ✅ Модальное окно "Статус системы" в `base.html` (info о БД, ошибки синхронизации)
|
||||||
|
- ✅ `configs.html`: badge с версией активного прайслиста
|
||||||
|
- ✅ Загрузка через `/api/pricelists/latest` при DOMContentLoaded
|
||||||
|
- ✅ Удалён dropdown с Push changes (упрощение UI)
|
||||||
|
|
||||||
|
**2. Прайслисты → вкладка в "Администратор цен": ✅ DONE**
|
||||||
|
- ✅ `base.html`: убрана ссылка "Прайслисты" из навигации
|
||||||
|
- ✅ `admin_pricing.html`: добавлена вкладка "Прайслисты"
|
||||||
|
- ✅ Логика перенесена из `pricelists.html` (table, create modal, CRUD)
|
||||||
|
- ✅ Route `/pricelists` → редирект на `/admin/pricing?tab=pricelists`
|
||||||
|
- ✅ Поддержка URL param `?tab=pricelists`
|
||||||
|
|
||||||
|
**3. Модал "Настройка цены" - кол-во котировок с учётом периода: ❌ TODO**
|
||||||
|
- Текущее: показывает только общее кол-во котировок
|
||||||
|
- Новое: показывать `N (всего: M)` где N - за выбранный период, M - всего
|
||||||
|
- ❌ `admin_pricing.html`: обновить `#modal-quote-count`
|
||||||
|
- ❌ `admin_pricing_handler.go`: в `/api/admin/pricing/preview` возвращать `quote_count_period` + `quote_count_total`
|
||||||
|
|
||||||
|
**4. Страница настроек: ❌ ОТЛОЖЕНО**
|
||||||
|
- Перенесено в Phase 3 (после основных UI улучшений)
|
||||||
|
|
||||||
|
### Phase 3: Projects and Specifications
|
||||||
|
- qt_projects, qt_specifications tables (MariaDB)
|
||||||
|
- Replace qt_configurations → Project/Specification hierarchy
|
||||||
|
- Fields: opty, customer_requirement, variant, qty, rev
|
||||||
|
- Local projects/specs with server sync
|
||||||
|
|
||||||
|
### Phase 4: Price Versioning
|
||||||
|
- Bind specifications to pricelist versions
|
||||||
|
- Price diff comparison
|
||||||
|
- Auto-cleanup expired pricelists (>1 year, usage_count=0)
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
Go 1.22+ | Gin | GORM | MariaDB 11 | SQLite (glebarez/sqlite) | htmx + Tailwind CDN | excelize
|
||||||
|
|
||||||
|
## Key Tables
|
||||||
|
|
||||||
|
### READ-ONLY (external systems)
|
||||||
|
- `lot` (lot_name PK, lot_description)
|
||||||
|
- `lot_log` (lot, supplier, date, price, quality, comments)
|
||||||
|
- `supplier` (supplier_name PK)
|
||||||
|
|
||||||
|
### MariaDB (qt_* prefix)
|
||||||
|
- `qt_lot_metadata` - component prices, methods, popularity
|
||||||
|
- `qt_categories` - category codes and names
|
||||||
|
- `qt_pricelists` - version snapshots (YYYY-MM-DD-NNN format)
|
||||||
|
- `qt_pricelist_items` - prices per pricelist
|
||||||
|
- `qt_projects` - uuid, opty, customer_requirement, name (Phase 3)
|
||||||
|
- `qt_specifications` - project_id, pricelist_id, variant, rev, qty, items JSON (Phase 3)
|
||||||
|
|
||||||
|
### SQLite (data/quoteforge.db)
|
||||||
|
- `connection_settings` - encrypted DB credentials (PasswordEncrypted field)
|
||||||
|
- `local_pricelists/items` - cached from server
|
||||||
|
- `local_components` - lot cache for offline search (with current_price)
|
||||||
|
- `local_configurations` - UUID, items, price_updated_at, sync_status (pending/synced/conflict), server_id
|
||||||
|
- `local_projects/specifications` - Phase 3
|
||||||
|
- `pending_changes` - sync queue (entity_type, uuid, op, payload, created_at, attempts, last_error)
|
||||||
|
|
||||||
|
## Business Logic
|
||||||
|
|
||||||
|
**Part number parsing:** `CPU_AMD_9654` → category=`CPU`, model=`AMD_9654`
|
||||||
|
|
||||||
|
**Price methods:** manual | median | average | weighted_median
|
||||||
|
|
||||||
|
**Price freshness:** fresh (<30d, ≥3 quotes) | normal (<60d) | stale (<90d) | critical
|
||||||
|
|
||||||
|
**Pricelist version:** `YYYY-MM-DD-NNN` (e.g., `2024-01-31-001`)
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Group | Endpoints |
|
||||||
|
|-------|-----------|
|
||||||
|
| Setup | GET/POST /setup, POST /setup/test |
|
||||||
|
| Components | GET /api/components, /api/categories |
|
||||||
|
| Pricelists | CRUD /api/pricelists, GET /latest, POST /compare |
|
||||||
|
| Projects | CRUD /api/projects/:uuid (Phase 3) |
|
||||||
|
| Specs | CRUD /api/specs/:uuid, POST /upgrade, GET /diff (Phase 3) |
|
||||||
|
| Configs | POST /:uuid/refresh-prices (обновить цены из local_components) |
|
||||||
|
| Sync | GET /status, POST /components, /pricelists, /push, /pull, /resolve-conflict |
|
||||||
|
| Export | GET /api/specs/:uuid/export, /api/projects/:uuid/export |
|
||||||
|
|
||||||
|
## Commands
|
||||||
```bash
|
```bash
|
||||||
# Verify build
|
# Development
|
||||||
go build ./cmd/qfs && go vet ./...
|
go run ./cmd/qfs # Dev server
|
||||||
|
make run # Dev server (via Makefile)
|
||||||
|
|
||||||
# Run
|
# Production build
|
||||||
go run ./cmd/qfs
|
make build-release # Optimized build with version (recommended)
|
||||||
make run
|
VERSION=$(git describe --tags --always --dirty)
|
||||||
|
CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=$VERSION" -o bin/qfs ./cmd/qfs
|
||||||
|
|
||||||
# Build
|
# Cron jobs
|
||||||
make build-release
|
go run ./cmd/cron -job=cleanup-pricelists # Remove old unused pricelists
|
||||||
|
go run ./cmd/cron -job=update-prices # Recalculate all prices
|
||||||
|
go run ./cmd/cron -job=update-popularity # Update popularity scores
|
||||||
|
|
||||||
|
# Check version
|
||||||
|
./bin/qfs -version
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
- gofmt, structured logging (slog), wrap errors with context
|
||||||
|
- snake_case files, PascalCase types
|
||||||
|
- RBAC disabled: DB username = user_id via `models.EnsureDBUser()`
|
||||||
|
|
||||||
|
## UI Guidelines
|
||||||
|
- htmx (hx-get/post/target/swap), Tailwind CDN
|
||||||
|
- Freshness colors: green (fresh) → yellow → orange → red (critical)
|
||||||
|
- Sync status + offline indicator in header
|
||||||
|
|||||||
178
LOCAL_FIRST_INTEGRATION.md
Normal file
178
LOCAL_FIRST_INTEGRATION.md
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
# 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
|
||||||
|
```
|
||||||
121
MIGRATION_PRICE_REFRESH.md
Normal file
121
MIGRATION_PRICE_REFRESH.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# Миграция: Функционал пересчета цен в конфигураторе
|
||||||
|
|
||||||
|
## Описание изменений
|
||||||
|
|
||||||
|
Добавлен функционал автоматического обновления цен компонентов в сохраненных конфигурациях.
|
||||||
|
|
||||||
|
### Новые возможности
|
||||||
|
|
||||||
|
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 install-hooks
|
.PHONY: build build-release clean test run version
|
||||||
|
|
||||||
# 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,12 +72,6 @@ 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"
|
||||||
@@ -98,7 +92,6 @@ 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)"
|
||||||
|
|||||||
392
README.md
392
README.md
@@ -1,66 +1,392 @@
|
|||||||
# QuoteForge
|
# QuoteForge
|
||||||
|
|
||||||
**Корпоративный конфигуратор серверов и расчёт КП**
|
**Server Configuration & Quotation Tool**
|
||||||
|
|
||||||
Offline-first архитектура: пользовательские операции через локальную SQLite, MariaDB только для синхронизации.
|
QuoteForge — корпоративный инструмент для конфигурирования серверов и формирования коммерческих предложений (КП). Приложение интегрируется с существующей базой данных RFQ_LOG.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
---
|
## Возможности
|
||||||
|
|
||||||
## Документация
|
### Для пользователей
|
||||||
|
- 📱 **Mobile-first интерфейс** — удобная работа с телефона и планшета
|
||||||
|
- 🖥️ **Конфигуратор серверов** — пошаговый выбор компонентов с проверкой совместимости
|
||||||
|
- 💰 **Автоматический расчёт цен** — актуальные цены на основе истории закупок
|
||||||
|
- 📊 **Экспорт в CSV/XLSX** — готовые спецификации для клиентов
|
||||||
|
- 💾 **Сохранение конфигураций** — история и шаблоны для повторного использования
|
||||||
|
|
||||||
Полная архитектурная документация хранится в **[bible/](bible/README.md)**:
|
### Для ценовых администраторов
|
||||||
|
- 📈 **Умный расчёт цен** — медиана, взвешенная медиана, среднее
|
||||||
|
- 🎯 **Система алертов** — уведомления о популярных компонентах с устаревшими ценами
|
||||||
|
- 📉 **Аналитика использования** — какие компоненты востребованы в КП
|
||||||
|
- ⚙️ **Гибкие настройки** — периоды расчёта, методы, ручные переопределения
|
||||||
|
|
||||||
| Файл | Тема |
|
### Индикация актуальности цен
|
||||||
|------|------|
|
| Цвет | Статус | Условие |
|
||||||
| [bible/01-overview.md](bible/01-overview.md) | Продукт, возможности, технологии, структура репо |
|
|------|--------|---------|
|
||||||
| [bible/02-architecture.md](bible/02-architecture.md) | Local-first, sync, ценообразование, версионность |
|
| 🟢 Зелёный | Свежая | < 30 дней, ≥ 3 источника |
|
||||||
| [bible/03-database.md](bible/03-database.md) | SQLite и MariaDB схемы, права, миграции |
|
| 🟡 Жёлтый | Нормальная | 30-60 дней |
|
||||||
| [bible/04-api.md](bible/04-api.md) | Все API endpoints и web-маршруты |
|
| 🟠 Оранжевый | Устаревающая | 60-90 дней |
|
||||||
| [bible/05-config.md](bible/05-config.md) | Конфигурация, env vars, установка |
|
| 🔴 Красный | Устаревшая | > 90 дней или нет данных |
|
||||||
| [bible/06-backup.md](bible/06-backup.md) | Резервное копирование |
|
|
||||||
| [bible/07-dev.md](bible/07-dev.md) | Команды разработки, стиль кода, guardrails |
|
|
||||||
|
|
||||||
---
|
## Технологии
|
||||||
|
|
||||||
## Быстрый старт
|
- **Backend:** Go 1.22+, Gin, GORM
|
||||||
|
- **Frontend:** HTML, Tailwind CSS, htmx
|
||||||
|
- **Database:** MariaDB 11+
|
||||||
|
- **Export:** excelize (XLSX), encoding/csv
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
|
||||||
|
- Go 1.22 или выше
|
||||||
|
- MariaDB 11.x (или MySQL 8.x)
|
||||||
|
- ~50 MB дискового пространства
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
### 1. Клонирование репозитория
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/your-company/quoteforge.git
|
||||||
|
cd quoteforge
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Настройка конфигурации
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp config.example.yaml config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Отредактируйте `config.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
server:
|
||||||
|
host: "0.0.0.0"
|
||||||
|
port: 8080
|
||||||
|
mode: "release"
|
||||||
|
|
||||||
|
database:
|
||||||
|
host: "localhost"
|
||||||
|
port: 3306
|
||||||
|
name: "RFQ_LOG"
|
||||||
|
user: "quoteforge"
|
||||||
|
password: "your-secure-password"
|
||||||
|
|
||||||
|
auth:
|
||||||
|
jwt_secret: "your-jwt-secret-min-32-chars"
|
||||||
|
token_expiry: "24h"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Миграции базы данных
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Применить миграции
|
|
||||||
go run ./cmd/qfs -migrate
|
go run ./cmd/qfs -migrate
|
||||||
|
|
||||||
# Запустить
|
|
||||||
go run ./cmd/qfs
|
|
||||||
# или
|
|
||||||
make run
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Приложение: http://localhost:8080 → откроется `/setup` для настройки подключения к MariaDB.
|
### Мигратор OPS -> проекты (preview/apply)
|
||||||
|
|
||||||
|
Переносит квоты, чьи названия начинаются с `OPS-xxxx` (где `x` — цифра), в проект `OPS-xxxx`.
|
||||||
|
Если проекта нет, он будет создан; если архивный — реактивирован.
|
||||||
|
|
||||||
|
Сначала всегда смотрите preview:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Сборка
|
go run ./cmd/migrate_ops_projects -config config.yaml
|
||||||
make build-release
|
|
||||||
|
|
||||||
# Проверка
|
|
||||||
go build ./cmd/qfs && go vet ./...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
Применение изменений:
|
||||||
|
|
||||||
## Releases & Changelog
|
```bash
|
||||||
|
go run ./cmd/migrate_ops_projects -config config.yaml -apply
|
||||||
|
```
|
||||||
|
|
||||||
Changelog между версиями: `releases/memory/v{major}.{minor}.{patch}.md`
|
Без интерактивного подтверждения:
|
||||||
|
|
||||||
---
|
```bash
|
||||||
|
go run ./cmd/migrate_ops_projects -config config.yaml -apply -yes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Минимальные права БД для пользователя квотаций
|
||||||
|
|
||||||
|
Если нужен пользователь, который может работать с конфигурациями, но не может создавать/удалять прайслисты:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 1) Создать (или оставить существующего) пользователя
|
||||||
|
CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY 'StrongPassword!';
|
||||||
|
|
||||||
|
-- 2) Сбросить лишние права (без пересоздания пользователя)
|
||||||
|
REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'quote_user'@'%';
|
||||||
|
|
||||||
|
-- 3) Чтение данных для конфигуратора и синка
|
||||||
|
GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%';
|
||||||
|
|
||||||
|
-- 4) Работа с конфигурациями
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
|
||||||
|
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
|
||||||
|
SHOW GRANTS FOR 'quote_user'@'%';
|
||||||
|
```
|
||||||
|
|
||||||
|
Важно:
|
||||||
|
- не выдавайте `INSERT/UPDATE/DELETE` на `qt_pricelists` и `qt_pricelist_items`, если пользователь не должен управлять прайслистами;
|
||||||
|
- если используется host-специфичный аккаунт (`'quote_user'@'192.168.x.x'`), назначьте права и для него;
|
||||||
|
- после смены DB-настроек через `/setup` приложение перезапускается автоматически и подхватывает нового пользователя.
|
||||||
|
|
||||||
|
### 4. Импорт метаданных компонентов
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/importer
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
go run ./cmd/qfs
|
||||||
|
|
||||||
|
# Production (with Makefile - recommended)
|
||||||
|
make build-release # Builds with version info
|
||||||
|
./bin/qfs -version # Check version
|
||||||
|
|
||||||
|
# Production (manual)
|
||||||
|
VERSION=$(git describe --tags --always --dirty)
|
||||||
|
CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=$VERSION" -o bin/qfs ./cmd/qfs
|
||||||
|
./bin/qfs -version
|
||||||
|
```
|
||||||
|
|
||||||
|
**Makefile команды:**
|
||||||
|
```bash
|
||||||
|
make build-release # Оптимизированная сборка с версией
|
||||||
|
make build-all # Сборка для всех платформ (Linux, macOS, Windows)
|
||||||
|
make build-windows # Только для Windows
|
||||||
|
make run # Запуск dev сервера
|
||||||
|
make test # Запуск тестов
|
||||||
|
make clean # Очистка bin/
|
||||||
|
make help # Показать все команды
|
||||||
|
```
|
||||||
|
|
||||||
|
Приложение будет доступно по адресу: http://localhost:8080
|
||||||
|
|
||||||
|
### Локальная SQLite база (state)
|
||||||
|
|
||||||
|
Локальная база приложения хранится в профиле пользователя и не зависит от расположения бинарника.
|
||||||
|
Имя файла: `qfs.db`.
|
||||||
|
|
||||||
|
- macOS: `~/Library/Application Support/QuoteForge/qfs.db`
|
||||||
|
- Linux: `$XDG_STATE_HOME/quoteforge/qfs.db` (или `~/.local/state/quoteforge/qfs.db`)
|
||||||
|
- Windows: `%LOCALAPPDATA%\\QuoteForge\\qfs.db`
|
||||||
|
|
||||||
|
Можно переопределить путь через `-localdb` или переменную окружения `QFS_DB_PATH`.
|
||||||
|
|
||||||
|
### Версионность конфигураций (local-first)
|
||||||
|
|
||||||
|
Для `local_configurations` используется append-only versioning через полные snapshot-версии:
|
||||||
|
|
||||||
|
- таблица: `local_configuration_versions`
|
||||||
|
- для каждого изменения создаётся новая версия (`version_no = max + 1`)
|
||||||
|
- `local_configurations.current_version_id` указывает на активную версию
|
||||||
|
- старые версии не изменяются и не удаляются в обычном потоке
|
||||||
|
- rollback не "перематывает" историю, а создаёт новую версию из выбранного snapshot
|
||||||
|
|
||||||
|
При backfill (миграция `006_add_local_configuration_versions.sql`) для существующих конфигураций создаётся `v1` и проставляется `current_version_id`.
|
||||||
|
|
||||||
|
#### Rollback
|
||||||
|
|
||||||
|
Rollback выполняется API-методом:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/configs/:uuid/rollback
|
||||||
|
{
|
||||||
|
"target_version": 3,
|
||||||
|
"note": "optional"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Результат:
|
||||||
|
- создаётся новая версия `vN` с `data` из целевой версии
|
||||||
|
- `change_note = "rollback to v{target_version}"` (+ note, если передан)
|
||||||
|
- `current_version_id` переключается на новую версию
|
||||||
|
- конфигурация уходит в `sync_status = pending`
|
||||||
|
|
||||||
|
### Локальный config.yaml
|
||||||
|
|
||||||
|
По умолчанию `qfs` ищет `config.yaml` в той же user-state папке, где лежит `qfs.db` (а не рядом с бинарником).
|
||||||
|
Можно переопределить путь через `-config` или `QFS_CONFIG_PATH`.
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Сборка образа
|
||||||
|
docker build -t quoteforge .
|
||||||
|
|
||||||
|
# Запуск с docker-compose
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
quoteforge/
|
||||||
|
├── cmd/
|
||||||
|
│ ├── server/main.go # Main HTTP server
|
||||||
|
│ └── importer/main.go # Import metadata from lot table
|
||||||
|
├── internal/
|
||||||
|
│ ├── config/ # Конфигурация
|
||||||
|
│ ├── models/ # GORM модели
|
||||||
|
│ ├── handlers/ # HTTP handlers
|
||||||
|
│ ├── services/ # Бизнес-логика
|
||||||
|
│ ├── middleware/ # Auth, CORS, etc.
|
||||||
|
│ └── repository/ # Работа с БД
|
||||||
|
├── web/
|
||||||
|
│ ├── templates/ # HTML шаблоны
|
||||||
|
│ └── static/ # CSS, JS, изображения
|
||||||
|
├── migrations/ # SQL миграции
|
||||||
|
├── config.yaml # Конфигурация
|
||||||
|
├── Dockerfile
|
||||||
|
├── docker-compose.yml
|
||||||
|
└── go.mod
|
||||||
|
```
|
||||||
|
|
||||||
|
## Роли пользователей
|
||||||
|
|
||||||
|
| Роль | Описание |
|
||||||
|
|------|----------|
|
||||||
|
| `viewer` | Просмотр, создание квот, экспорт |
|
||||||
|
| `editor` | + сохранение конфигураций |
|
||||||
|
| `pricing_admin` | + управление ценами и алертами |
|
||||||
|
| `admin` | Полный доступ, управление пользователями |
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Документация API доступна по адресу `/api/docs` (в разработке).
|
||||||
|
|
||||||
|
Основные endpoints:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/auth/login # Авторизация
|
||||||
|
GET /api/components # Список компонентов
|
||||||
|
POST /api/quote/calculate # Расчёт цены
|
||||||
|
POST /api/export/xlsx # Экспорт в Excel
|
||||||
|
GET /api/configs # Сохранённые конфигурации
|
||||||
|
GET /api/configs/:uuid/versions # Список версий конфигурации
|
||||||
|
GET /api/configs/:uuid/versions/:version # Получить конкретную версию
|
||||||
|
POST /api/configs/:uuid/rollback # Rollback на указанную версию
|
||||||
|
POST /api/configs/:uuid/reactivate # Вернуть архивную конфигурацию в активные
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Sync payload для versioning
|
||||||
|
|
||||||
|
События в `pending_changes` для конфигураций содержат:
|
||||||
|
- `configuration_uuid`
|
||||||
|
- `operation` (`create` / `update` / `rollback`)
|
||||||
|
- `current_version_id` и `current_version_no`
|
||||||
|
- `snapshot` (текущее состояние конфигурации)
|
||||||
|
- `idempotency_key` и `conflict_policy` (`last_write_wins`)
|
||||||
|
|
||||||
|
Это позволяет push-слою отправлять на сервер актуальное состояние и готовит основу для будущего conflict resolution.
|
||||||
|
|
||||||
|
## Cron Jobs
|
||||||
|
|
||||||
|
QuoteForge now includes automated cron jobs for maintenance tasks. These can be run using the built-in cron functionality in the Docker container.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
# Запуск в режиме разработки (hot reload)
|
||||||
|
go run ./cmd/qfs
|
||||||
|
|
||||||
|
# Запуск тестов
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
# Сборка для Linux
|
||||||
|
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/qfs ./cmd/qfs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Переменные окружения
|
||||||
|
|
||||||
|
| Переменная | Описание | По умолчанию |
|
||||||
|
|------------|----------|--------------|
|
||||||
|
| `QF_DB_HOST` | Хост базы данных | localhost |
|
||||||
|
| `QF_DB_PORT` | Порт базы данных | 3306 |
|
||||||
|
| `QF_DB_NAME` | Имя базы данных | RFQ_LOG |
|
||||||
|
| `QF_DB_USER` | Пользователь БД | — |
|
||||||
|
| `QF_DB_PASSWORD` | Пароль БД | — |
|
||||||
|
| `QF_JWT_SECRET` | Секрет для JWT | — |
|
||||||
|
| `QF_SERVER_PORT` | Порт сервера | 8080 |
|
||||||
|
| `QFS_DB_PATH` | Полный путь к локальной SQLite БД | OS-specific user state dir |
|
||||||
|
| `QFS_STATE_DIR` | Каталог state (если `QFS_DB_PATH` не задан) | OS-specific user state dir |
|
||||||
|
| `QFS_CONFIG_PATH` | Полный путь к `config.yaml` | OS-specific user state dir |
|
||||||
|
|
||||||
|
## Интеграция с существующей БД
|
||||||
|
|
||||||
|
QuoteForge интегрируется с существующей базой RFQ_LOG:
|
||||||
|
|
||||||
|
- `lot` — справочник компонентов (только чтение)
|
||||||
|
- `lot_log` — история цен от поставщиков (только чтение)
|
||||||
|
- `supplier` — справочник поставщиков (только чтение)
|
||||||
|
|
||||||
|
Новые таблицы QuoteForge имеют префикс `qt_`:
|
||||||
|
|
||||||
|
- `qt_users` — пользователи приложения
|
||||||
|
- `qt_lot_metadata` — расширенные данные компонентов
|
||||||
|
- `qt_configurations` — сохранённые конфигурации
|
||||||
|
- `qt_pricing_alerts` — алерты для администраторов
|
||||||
|
|
||||||
## Поддержка
|
## Поддержка
|
||||||
|
|
||||||
|
По вопросам работы приложения обращайтесь:
|
||||||
- Email: mike@mchus.pro
|
- Email: mike@mchus.pro
|
||||||
- Internal: @mchus
|
- Internal: @mchus
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
Собственность компании, только для внутреннего использования. См. [LICENSE](LICENSE).
|
Данное программное обеспечение является собственностью компании и предназначено исключительно для внутреннего использования. Распространение, копирование или модификация без письменного разрешения запрещены.
|
||||||
|
|
||||||
|
См. файл [LICENSE](LICENSE) для подробностей.
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
# 01 — Product Overview
|
|
||||||
|
|
||||||
## What is QuoteForge
|
|
||||||
|
|
||||||
A corporate server configuration and quotation tool.
|
|
||||||
Operates in **strict local-first** mode: all user operations go through local SQLite; MariaDB is used only for synchronization.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
### For Users
|
|
||||||
- Mobile-first interface — works comfortably on phones and tablets
|
|
||||||
- Server configurator — step-by-step component selection
|
|
||||||
- Automatic price calculation — based on pricelists from local cache
|
|
||||||
- CSV export — ready-to-use specifications for clients
|
|
||||||
- Configuration history — versioned snapshots with rollback support
|
|
||||||
- Full offline operation — continue working without network, sync later
|
|
||||||
- Guarded synchronization — sync is blocked by preflight check if local schema is not ready
|
|
||||||
|
|
||||||
### User Roles
|
|
||||||
|
|
||||||
| Role | Permissions |
|
|
||||||
|------|-------------|
|
|
||||||
| `viewer` | View, create quotes, export |
|
|
||||||
| `editor` | + save configurations |
|
|
||||||
| `pricing_admin` | + manage prices and alerts |
|
|
||||||
| `admin` | Full access, user management |
|
|
||||||
|
|
||||||
### Price Freshness Indicators
|
|
||||||
|
|
||||||
| Color | Status | Condition |
|
|
||||||
|-------|--------|-----------|
|
|
||||||
| Green | Fresh | < 30 days, ≥ 3 sources |
|
|
||||||
| Yellow | Normal | 30–60 days |
|
|
||||||
| Orange | Aging | 60–90 days |
|
|
||||||
| Red | Stale | > 90 days or no data |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
| Layer | Stack |
|
|
||||||
|-------|-------|
|
|
||||||
| Backend | Go 1.22+, Gin, GORM |
|
|
||||||
| Frontend | HTML, Tailwind CSS, htmx |
|
|
||||||
| Local DB | SQLite (`qfs.db`) |
|
|
||||||
| Server DB | MariaDB 11+ (sync + server admin) |
|
|
||||||
| Export | encoding/csv, excelize (XLSX) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Product Scope
|
|
||||||
|
|
||||||
**In scope:**
|
|
||||||
- Component configurator and quotation calculation
|
|
||||||
- Projects and configurations
|
|
||||||
- Read-only pricelist viewing from local cache
|
|
||||||
- Sync (pull components/pricelists, push local changes)
|
|
||||||
|
|
||||||
**Out of scope (removed intentionally — do not restore):**
|
|
||||||
- Admin pricing UI/API
|
|
||||||
- Stock import
|
|
||||||
- Alerts
|
|
||||||
- Cron/importer utilities
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Repository Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
quoteforge/
|
|
||||||
├── cmd/
|
|
||||||
│ ├── qfs/main.go # HTTP server entry point
|
|
||||||
│ ├── migrate/ # Migration tool
|
|
||||||
│ └── migrate_ops_projects/ # OPS project migrator
|
|
||||||
├── internal/
|
|
||||||
│ ├── appmeta/ # App version metadata
|
|
||||||
│ ├── appstate/ # State management, backup
|
|
||||||
│ ├── article/ # Article generation
|
|
||||||
│ ├── config/ # Config parsing
|
|
||||||
│ ├── db/ # DB initialization
|
|
||||||
│ ├── handlers/ # HTTP handlers
|
|
||||||
│ ├── localdb/ # SQLite layer
|
|
||||||
│ ├── lotmatch/ # Lot matching logic
|
|
||||||
│ ├── middleware/ # Auth, CORS, etc.
|
|
||||||
│ ├── models/ # GORM models
|
|
||||||
│ ├── repository/ # Repository layer
|
|
||||||
│ └── services/ # Business logic
|
|
||||||
├── web/
|
|
||||||
│ ├── templates/ # HTML templates + partials
|
|
||||||
│ └── static/ # CSS, JS, assets
|
|
||||||
├── migrations/ # SQL migration files (30+)
|
|
||||||
├── bible/ # Architectural documentation (this section)
|
|
||||||
├── releases/memory/ # Per-version changelogs
|
|
||||||
├── config.example.yaml # Config template (the only one in repo)
|
|
||||||
└── go.mod
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Integration with Existing DB
|
|
||||||
|
|
||||||
QuoteForge integrates with the existing `RFQ_LOG` database:
|
|
||||||
|
|
||||||
**Read-only:**
|
|
||||||
- `lot` — component catalog
|
|
||||||
- `qt_lot_metadata` — extended component data
|
|
||||||
- `qt_categories` — categories
|
|
||||||
- `qt_pricelists`, `qt_pricelist_items` — pricelists
|
|
||||||
|
|
||||||
**Read + Write:**
|
|
||||||
- `qt_configurations` — configurations
|
|
||||||
- `qt_projects` — projects
|
|
||||||
|
|
||||||
**Sync service tables:**
|
|
||||||
- `qt_client_local_migrations` — migration catalog (SELECT only)
|
|
||||||
- `qt_client_schema_state` — applied migrations state
|
|
||||||
- `qt_pricelist_sync_status` — pricelist sync status
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
# 02 — Architecture
|
|
||||||
|
|
||||||
## Local-First Principle
|
|
||||||
|
|
||||||
**SQLite** is the single source of truth for the user.
|
|
||||||
**MariaDB** is a sync server only — it never blocks local operations.
|
|
||||||
|
|
||||||
```
|
|
||||||
User
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
SQLite (qfs.db) ← all CRUD operations go here
|
|
||||||
│
|
|
||||||
│ background sync (every 5 min)
|
|
||||||
▼
|
|
||||||
MariaDB (RFQ_LOG) ← pull/push only
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rules:**
|
|
||||||
- All CRUD operations go through SQLite only
|
|
||||||
- If MariaDB is unavailable → local work continues without restrictions
|
|
||||||
- Changes are queued in `pending_changes` and pushed on next sync
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Synchronization
|
|
||||||
|
|
||||||
### Data Flow Diagram
|
|
||||||
|
|
||||||
```
|
|
||||||
[ SERVER / MariaDB ]
|
|
||||||
┌───────────────────────────┐
|
|
||||||
│ qt_projects │
|
|
||||||
│ qt_configurations │
|
|
||||||
│ qt_pricelists │
|
|
||||||
│ qt_pricelist_items │
|
|
||||||
│ qt_pricelist_sync_status │
|
|
||||||
└─────────────┬─────────────┘
|
|
||||||
│
|
|
||||||
pull (projects/configs/pricelists)
|
|
||||||
│
|
|
||||||
┌────────────────────┴────────────────────┐
|
|
||||||
│ │
|
|
||||||
[ CLIENT A / SQLite ] [ CLIENT B / SQLite ]
|
|
||||||
local_projects local_projects
|
|
||||||
local_configurations local_configurations
|
|
||||||
local_pricelists local_pricelists
|
|
||||||
local_pricelist_items local_pricelist_items
|
|
||||||
pending_changes pending_changes
|
|
||||||
│ │
|
|
||||||
└────── push (projects/configs only) ─────┘
|
|
||||||
│
|
|
||||||
[ SERVER / MariaDB ]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Sync Direction by Entity
|
|
||||||
|
|
||||||
| Entity | Direction |
|
|
||||||
|--------|-----------|
|
|
||||||
| Configurations | Client ↔ Server ↔ Other Clients |
|
|
||||||
| Projects | Client ↔ Server ↔ Other Clients |
|
|
||||||
| Pricelists | Server → Clients only (no push) |
|
|
||||||
| Components | Server → Clients only |
|
|
||||||
|
|
||||||
Local pricelists not present on the server and not referenced by active configurations are deleted automatically on sync.
|
|
||||||
|
|
||||||
### Soft Deletes (Archive Pattern)
|
|
||||||
|
|
||||||
Configurations and projects are **never hard-deleted**. Deletion is archive via `is_active = false`.
|
|
||||||
|
|
||||||
- `DELETE /api/configs/:uuid` → sets `is_active = false` (archived); can be restored via `reactivate`
|
|
||||||
- `DELETE /api/projects/:uuid` → archives a project **variant** only (`variant` field must be non-empty); main projects cannot be deleted via this endpoint
|
|
||||||
|
|
||||||
## Sync Readiness Guard
|
|
||||||
|
|
||||||
Before every push/pull, a preflight check runs:
|
|
||||||
1. Is the server (MariaDB) reachable?
|
|
||||||
2. Can centralized local DB migrations be applied?
|
|
||||||
3. Does the application version satisfy `min_app_version` of pending migrations?
|
|
||||||
|
|
||||||
**If the check fails:**
|
|
||||||
- Local CRUD continues without restriction
|
|
||||||
- Sync API returns `423 Locked` with `reason_code` and `reason_text`
|
|
||||||
- UI shows a red indicator with the block reason
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Pricing
|
|
||||||
|
|
||||||
### Principle
|
|
||||||
|
|
||||||
**Prices come only from `local_pricelist_items`.**
|
|
||||||
Components (`local_components`) are metadata-only — they contain no pricing information.
|
|
||||||
|
|
||||||
### Lookup Pattern
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Look up a price for a line item
|
|
||||||
price, found := s.lookupPriceByPricelistID(pricelistID, lotName)
|
|
||||||
if found && price > 0 {
|
|
||||||
// use price
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inside lookupPriceByPricelistID:
|
|
||||||
localPL, err := s.localDB.GetLocalPricelistByServerID(pricelistID)
|
|
||||||
price, err := s.localDB.GetLocalPriceForLot(localPL.ID, lotName)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Multi-Level Pricelists
|
|
||||||
|
|
||||||
A configuration can reference up to three pricelists simultaneously:
|
|
||||||
|
|
||||||
| Field | Purpose |
|
|
||||||
|-------|---------|
|
|
||||||
| `pricelist_id` | Primary (estimate) |
|
|
||||||
| `warehouse_pricelist_id` | Warehouse pricing |
|
|
||||||
| `competitor_pricelist_id` | Competitor pricing |
|
|
||||||
|
|
||||||
Pricelist sources: `estimate` | `warehouse` | `competitor`
|
|
||||||
|
|
||||||
### "Auto" Pricelist Selection
|
|
||||||
|
|
||||||
Configurator supports explicit and automatic selection per source (`estimate`, `warehouse`, `competitor`):
|
|
||||||
|
|
||||||
- **Explicit mode:** concrete `pricelist_id` is set by user in settings.
|
|
||||||
- **Auto mode:** client sends no explicit ID for that source; backend resolves the current latest active pricelist.
|
|
||||||
|
|
||||||
`auto` must stay `auto` after price-level refresh and after manual "refresh prices":
|
|
||||||
- resolved IDs are runtime-only and must not overwrite user's mode;
|
|
||||||
- switching to explicit selection must clear runtime auto resolution for that source.
|
|
||||||
|
|
||||||
### Latest Pricelist Resolution Rules
|
|
||||||
|
|
||||||
For both server (`qt_pricelists`) and local cache (`local_pricelists`), "latest by source" is resolved with:
|
|
||||||
|
|
||||||
1. only pricelists that have at least one item (`EXISTS ...pricelist_items`);
|
|
||||||
2. deterministic sort: `created_at DESC, id DESC`.
|
|
||||||
|
|
||||||
This prevents selecting empty/incomplete snapshots and removes nondeterministic ties.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration Versioning
|
|
||||||
|
|
||||||
### Principle
|
|
||||||
|
|
||||||
Append-only: every save creates an immutable snapshot in `local_configuration_versions`.
|
|
||||||
|
|
||||||
```
|
|
||||||
local_configurations
|
|
||||||
└── current_version_id ──► local_configuration_versions (v3) ← active
|
|
||||||
local_configuration_versions (v2)
|
|
||||||
local_configuration_versions (v1)
|
|
||||||
```
|
|
||||||
|
|
||||||
- `version_no = max + 1` on every save
|
|
||||||
- Old versions are never modified or deleted in normal flow
|
|
||||||
- Rollback does **not** rewind history — it creates a **new** version from the snapshot
|
|
||||||
|
|
||||||
### Rollback
|
|
||||||
|
|
||||||
```bash
|
|
||||||
POST /api/configs/:uuid/rollback
|
|
||||||
{
|
|
||||||
"target_version": 3,
|
|
||||||
"note": "optional comment"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Result:
|
|
||||||
- A new version `vN` is created with `data` from the target version
|
|
||||||
- `change_note = "rollback to v{target_version}"` (+ note if provided)
|
|
||||||
- `current_version_id` is switched to the new version
|
|
||||||
- Configuration moves to `sync_status = pending`
|
|
||||||
|
|
||||||
### Sync Status Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
local → pending → synced
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Sync Payload for Versioning
|
|
||||||
|
|
||||||
Events in `pending_changes` for configurations contain:
|
|
||||||
|
|
||||||
| Field | Description |
|
|
||||||
|-------|-------------|
|
|
||||||
| `configuration_uuid` | Identifier |
|
|
||||||
| `operation` | `create` / `update` / `rollback` |
|
|
||||||
| `current_version_id` | Active version ID |
|
|
||||||
| `current_version_no` | Version number |
|
|
||||||
| `snapshot` | Current configuration state |
|
|
||||||
| `idempotency_key` | For idempotent push |
|
|
||||||
| `conflict_policy` | `last_write_wins` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Background Processes
|
|
||||||
|
|
||||||
| Process | Interval | What it does |
|
|
||||||
|---------|----------|--------------|
|
|
||||||
| Sync worker | 5 min | push pending + pull all |
|
|
||||||
| Backup scheduler | configurable (`backup.time`) | creates ZIP archives |
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
# 03 — Database
|
|
||||||
|
|
||||||
## SQLite (local, client-side)
|
|
||||||
|
|
||||||
File: `qfs.db` in the user-state directory (see [05-config.md](05-config.md)).
|
|
||||||
|
|
||||||
### Tables
|
|
||||||
|
|
||||||
#### Components and Reference Data
|
|
||||||
|
|
||||||
| Table | Purpose | Key Fields |
|
|
||||||
|-------|---------|------------|
|
|
||||||
| `local_components` | Component metadata (NO prices) | `lot_name` (PK), `lot_description`, `category`, `model` |
|
|
||||||
| `connection_settings` | MariaDB connection settings | key-value store |
|
|
||||||
| `app_settings` | Application settings | `key` (PK), `value`, `updated_at` |
|
|
||||||
|
|
||||||
#### Pricelists
|
|
||||||
|
|
||||||
| Table | Purpose | Key Fields |
|
|
||||||
|-------|---------|------------|
|
|
||||||
| `local_pricelists` | Pricelist headers | `id`, `server_id` (unique), `source`, `version`, `created_at` |
|
|
||||||
| `local_pricelist_items` | Pricelist line items ← **sole source of prices** | `id`, `pricelist_id` (FK), `lot_name`, `price`, `lot_category` |
|
|
||||||
|
|
||||||
#### Configurations and Projects
|
|
||||||
|
|
||||||
| Table | Purpose | Key Fields |
|
|
||||||
|-------|---------|------------|
|
|
||||||
| `local_configurations` | Saved configurations | `id`, `uuid` (unique), `items` (JSON), `pricelist_id`, `warehouse_pricelist_id`, `competitor_pricelist_id`, `current_version_id`, `sync_status` |
|
|
||||||
| `local_configuration_versions` | Immutable snapshots (revisions) | `id`, `configuration_id` (FK), `version_no`, `data` (JSON), `change_note`, `created_at` |
|
|
||||||
| `local_projects` | Projects | `id`, `uuid` (unique), `name`, `code`, `sync_status` |
|
|
||||||
|
|
||||||
#### Sync
|
|
||||||
|
|
||||||
| Table | Purpose |
|
|
||||||
|-------|---------|
|
|
||||||
| `pending_changes` | Queue of changes to push to MariaDB |
|
|
||||||
| `local_schema_migrations` | Applied migrations (idempotency guard) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Key SQLite Indexes
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Pricelists
|
|
||||||
INDEX local_pricelist_items(pricelist_id)
|
|
||||||
UNIQUE INDEX local_pricelists(server_id)
|
|
||||||
INDEX local_pricelists(source, created_at) -- used for "latest by source" queries
|
|
||||||
-- latest-by-source runtime query also applies deterministic tie-break by id DESC
|
|
||||||
|
|
||||||
-- Configurations
|
|
||||||
INDEX local_configurations(pricelist_id)
|
|
||||||
INDEX local_configurations(warehouse_pricelist_id)
|
|
||||||
INDEX local_configurations(competitor_pricelist_id)
|
|
||||||
UNIQUE INDEX local_configurations(uuid)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `items` JSON Structure in Configurations
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"lot_name": "CPU_AMD_9654",
|
|
||||||
"quantity": 2,
|
|
||||||
"unit_price": 123456.78,
|
|
||||||
"section": "Processors"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Prices are stored inside the `items` JSON field and refreshed from the pricelist on configuration refresh.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## MariaDB (server-side, sync-only)
|
|
||||||
|
|
||||||
Database: `RFQ_LOG`
|
|
||||||
|
|
||||||
### Tables and Permissions
|
|
||||||
|
|
||||||
| Table | Purpose | Permissions |
|
|
||||||
|-------|---------|-------------|
|
|
||||||
| `lot` | Component catalog | SELECT |
|
|
||||||
| `qt_lot_metadata` | Extended component data | SELECT |
|
|
||||||
| `qt_categories` | Component categories | SELECT |
|
|
||||||
| `qt_pricelists` | Pricelists | SELECT |
|
|
||||||
| `qt_pricelist_items` | Pricelist line items | SELECT |
|
|
||||||
| `lot_partnumbers` | Partnumber → lot mapping (pricelist enrichment) | SELECT |
|
|
||||||
| `stock_log` | Latest stock qty by partnumber (pricelist enrichment) | SELECT |
|
|
||||||
| `qt_configurations` | Saved configurations | SELECT, INSERT, UPDATE |
|
|
||||||
| `qt_projects` | Projects | SELECT, INSERT, UPDATE |
|
|
||||||
| `qt_client_local_migrations` | Migration catalog | SELECT only |
|
|
||||||
| `qt_client_schema_state` | Applied migrations state | SELECT, INSERT, UPDATE |
|
|
||||||
| `qt_pricelist_sync_status` | Pricelist sync status | SELECT, INSERT, UPDATE |
|
|
||||||
|
|
||||||
### Grant Permissions to Existing User
|
|
||||||
|
|
||||||
```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 ON RFQ_LOG.lot_partnumbers TO '<DB_USER>'@'%';
|
|
||||||
GRANT SELECT ON RFQ_LOG.stock_log 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;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Create a New User
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY '<DB_PASSWORD>';
|
|
||||||
|
|
||||||
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 ON RFQ_LOG.lot_partnumbers TO 'quote_user'@'%';
|
|
||||||
GRANT SELECT ON RFQ_LOG.stock_log 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'@'%';
|
|
||||||
|
|
||||||
FLUSH PRIVILEGES;
|
|
||||||
SHOW GRANTS FOR 'quote_user'@'%';
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note:** If pricelists sync but show `0` positions (or logs contain `enriching pricelist items with stock` + `SELECT denied`), verify `SELECT` on `lot_partnumbers` and `stock_log` in addition to `qt_pricelist_items`.
|
|
||||||
|
|
||||||
**Note:** If you see `Access denied for user ...@'<ip>'`, check for conflicting user entries (user@localhost vs user@'%').
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Migrations
|
|
||||||
|
|
||||||
### SQLite Migrations (local)
|
|
||||||
|
|
||||||
- Stored in `migrations/` (SQL files)
|
|
||||||
- Applied via `-migrate` flag or automatically on first run
|
|
||||||
- Idempotent: checked by `id` in `local_schema_migrations`
|
|
||||||
- Already-applied migrations are skipped
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go run ./cmd/qfs -migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
### Centralized Migrations (server-side)
|
|
||||||
|
|
||||||
- Stored in `qt_client_local_migrations` (MariaDB)
|
|
||||||
- Applied automatically during sync readiness check
|
|
||||||
- `min_app_version` — minimum app version required for the migration
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DB Debugging
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Inspect schema
|
|
||||||
sqlite3 ~/.local/state/quoteforge/qfs.db ".schema local_components"
|
|
||||||
sqlite3 ~/.local/state/quoteforge/qfs.db ".schema local_configurations"
|
|
||||||
|
|
||||||
# Check pricelist item count
|
|
||||||
sqlite3 ~/.local/state/quoteforge/qfs.db "SELECT COUNT(*) FROM local_pricelist_items"
|
|
||||||
|
|
||||||
# Check pending sync queue
|
|
||||||
sqlite3 ~/.local/state/quoteforge/qfs.db "SELECT COUNT(*) FROM pending_changes"
|
|
||||||
```
|
|
||||||
127
bible/04-api.md
127
bible/04-api.md
@@ -1,127 +0,0 @@
|
|||||||
# 04 — API and Web Routes
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
### Setup
|
|
||||||
|
|
||||||
| Method | Endpoint | Purpose |
|
|
||||||
|--------|----------|---------|
|
|
||||||
| GET | `/setup` | Initial setup page |
|
|
||||||
| POST | `/setup` | Save connection settings |
|
|
||||||
| POST | `/setup/test` | Test MariaDB connection |
|
|
||||||
| GET | `/setup/status` | Setup status |
|
|
||||||
|
|
||||||
### Components
|
|
||||||
|
|
||||||
| Method | Endpoint | Purpose |
|
|
||||||
|--------|----------|---------|
|
|
||||||
| GET | `/api/components` | List components (metadata only) |
|
|
||||||
| GET | `/api/components/:lot_name` | Component by lot_name |
|
|
||||||
| GET | `/api/categories` | List categories |
|
|
||||||
|
|
||||||
### Quote
|
|
||||||
|
|
||||||
| Method | Endpoint | Purpose |
|
|
||||||
|--------|----------|---------|
|
|
||||||
| POST | `/api/quote/validate` | Validate line items |
|
|
||||||
| POST | `/api/quote/calculate` | Calculate quote (prices from pricelist) |
|
|
||||||
| POST | `/api/quote/price-levels` | Prices by level (estimate/warehouse/competitor) |
|
|
||||||
|
|
||||||
### Pricelists (read-only)
|
|
||||||
|
|
||||||
| Method | Endpoint | Purpose |
|
|
||||||
|--------|----------|---------|
|
|
||||||
| GET | `/api/pricelists` | List pricelists (`source`, `active_only`, pagination) |
|
|
||||||
| GET | `/api/pricelists/latest` | Latest pricelist by source |
|
|
||||||
| GET | `/api/pricelists/:id` | Pricelist by ID |
|
|
||||||
| GET | `/api/pricelists/:id/items` | Pricelist line items |
|
|
||||||
| GET | `/api/pricelists/:id/lots` | Lot names in pricelist |
|
|
||||||
|
|
||||||
`GET /api/pricelists?active_only=true` returns only pricelists that have synced items (`item_count > 0`).
|
|
||||||
|
|
||||||
### Configurations
|
|
||||||
|
|
||||||
| Method | Endpoint | Purpose |
|
|
||||||
|--------|----------|---------|
|
|
||||||
| GET | `/api/configs` | List configurations |
|
|
||||||
| POST | `/api/configs` | Create configuration |
|
|
||||||
| GET | `/api/configs/:uuid` | Get configuration |
|
|
||||||
| PUT | `/api/configs/:uuid` | Update configuration |
|
|
||||||
| DELETE | `/api/configs/:uuid` | Archive configuration |
|
|
||||||
| POST | `/api/configs/:uuid/refresh-prices` | Refresh prices from pricelist |
|
|
||||||
| POST | `/api/configs/:uuid/clone` | Clone configuration |
|
|
||||||
| POST | `/api/configs/:uuid/reactivate` | Restore archived configuration |
|
|
||||||
| POST | `/api/configs/:uuid/rename` | Rename configuration |
|
|
||||||
| POST | `/api/configs/preview-article` | Preview generated article for a configuration |
|
|
||||||
| POST | `/api/configs/:uuid/rollback` | Roll back to a version |
|
|
||||||
| GET | `/api/configs/:uuid/versions` | List versions |
|
|
||||||
| GET | `/api/configs/:uuid/versions/:version` | Get specific version |
|
|
||||||
|
|
||||||
### Projects
|
|
||||||
|
|
||||||
| Method | Endpoint | Purpose |
|
|
||||||
|--------|----------|---------|
|
|
||||||
| GET | `/api/projects` | List projects |
|
|
||||||
| POST | `/api/projects` | Create project |
|
|
||||||
| GET | `/api/projects/:uuid` | Get project |
|
|
||||||
| PUT | `/api/projects/:uuid` | Update project |
|
|
||||||
| DELETE | `/api/projects/:uuid` | Archive project variant (soft-delete via `is_active=false`; fails if project has no `variant` set — main projects cannot be deleted this way) |
|
|
||||||
| GET | `/api/projects/:uuid/configs` | Project configurations |
|
|
||||||
|
|
||||||
### Sync
|
|
||||||
|
|
||||||
| Method | Endpoint | Purpose | Flow |
|
|
||||||
|--------|----------|---------|------|
|
|
||||||
| GET | `/api/sync/status` | Overall sync status | read-only |
|
|
||||||
| GET | `/api/sync/readiness` | Preflight status (ready/blocked/unknown) | read-only |
|
|
||||||
| GET | `/api/sync/info` | Data for sync modal | read-only |
|
|
||||||
| GET | `/api/sync/users-status` | Users status | read-only |
|
|
||||||
| GET | `/api/sync/pending` | List pending changes | read-only |
|
|
||||||
| GET | `/api/sync/pending/count` | Count of pending changes | read-only |
|
|
||||||
| POST | `/api/sync/push` | Push pending → MariaDB | SQLite → MariaDB |
|
|
||||||
| POST | `/api/sync/components` | Pull components | MariaDB → SQLite |
|
|
||||||
| POST | `/api/sync/pricelists` | Pull pricelists | MariaDB → SQLite |
|
|
||||||
| POST | `/api/sync/all` | Full sync: push + pull + import | bidirectional |
|
|
||||||
| POST | `/api/sync/repair` | Repair broken entries in pending_changes | SQLite |
|
|
||||||
|
|
||||||
**If sync is blocked by the readiness guard:** all POST sync methods return `423 Locked` with `reason_code` and `reason_text`.
|
|
||||||
|
|
||||||
### Export
|
|
||||||
|
|
||||||
| Method | Endpoint | Purpose |
|
|
||||||
|--------|----------|---------|
|
|
||||||
| POST | `/api/export/csv` | Export configuration to CSV |
|
|
||||||
|
|
||||||
**Export filename format:** `YYYY-MM-DD (ProjectCode) ConfigName Article.csv`
|
|
||||||
(uses `project.Code`, not `project.Name`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Web Routes
|
|
||||||
|
|
||||||
| Route | Page |
|
|
||||||
|-------|------|
|
|
||||||
| `/configs` | Configuration list |
|
|
||||||
| `/configurator` | Configurator |
|
|
||||||
| `/configs/:uuid/revisions` | Configuration revision history |
|
|
||||||
| `/projects` | Project list |
|
|
||||||
| `/projects/:uuid` | Project details |
|
|
||||||
| `/pricelists` | Pricelist list |
|
|
||||||
| `/pricelists/:id` | Pricelist details |
|
|
||||||
| `/setup` | Connection settings |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Rollback API (details)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
POST /api/configs/:uuid/rollback
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"target_version": 3,
|
|
||||||
"note": "optional comment"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Response: updated configuration with the new version.
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
# 05 — Configuration and Environment
|
|
||||||
|
|
||||||
## File Paths
|
|
||||||
|
|
||||||
### SQLite database (`qfs.db`)
|
|
||||||
|
|
||||||
| OS | Default path |
|
|
||||||
|----|-------------|
|
|
||||||
| macOS | `~/Library/Application Support/QuoteForge/qfs.db` |
|
|
||||||
| Linux | `$XDG_STATE_HOME/quoteforge/qfs.db` or `~/.local/state/quoteforge/qfs.db` |
|
|
||||||
| Windows | `%LOCALAPPDATA%\QuoteForge\qfs.db` |
|
|
||||||
|
|
||||||
Override: `-localdb <path>` or `QFS_DB_PATH`.
|
|
||||||
|
|
||||||
### config.yaml
|
|
||||||
|
|
||||||
Searched in the same user-state directory as `qfs.db` by default.
|
|
||||||
If the file does not exist, it is created automatically.
|
|
||||||
If the format is outdated, it is automatically migrated to the runtime format (`server` + `logging` sections only).
|
|
||||||
|
|
||||||
Override: `-config <path>` or `QFS_CONFIG_PATH`.
|
|
||||||
|
|
||||||
**Important:** `config.yaml` is a runtime user file — it is **not stored in the repository**.
|
|
||||||
`config.example.yaml` is the only config template in the repo.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## config.yaml Structure
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
server:
|
|
||||||
host: "0.0.0.0"
|
|
||||||
port: 8080
|
|
||||||
mode: "release" # release | debug
|
|
||||||
|
|
||||||
logging:
|
|
||||||
level: "info" # debug | info | warn | error
|
|
||||||
format: "json" # json | text
|
|
||||||
output: "stdout" # stdout | stderr | /path/to/file
|
|
||||||
|
|
||||||
backup:
|
|
||||||
time: "00:00" # HH:MM in local time
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
| Variable | Description | Default |
|
|
||||||
|----------|-------------|---------|
|
|
||||||
| `QFS_DB_PATH` | Full path to SQLite DB | OS-specific user state dir |
|
|
||||||
| `QFS_STATE_DIR` | State directory (if `QFS_DB_PATH` is not set) | OS-specific user state dir |
|
|
||||||
| `QFS_CONFIG_PATH` | Full path to `config.yaml` | OS-specific user state dir |
|
|
||||||
| `QFS_BACKUP_DIR` | Root directory for rotating backups | `<db dir>/backups` |
|
|
||||||
| `QFS_BACKUP_DISABLE` | Disable automatic backups | — |
|
|
||||||
| `QF_DB_HOST` | MariaDB host | localhost |
|
|
||||||
| `QF_DB_PORT` | MariaDB port | 3306 |
|
|
||||||
| `QF_DB_NAME` | Database name | RFQ_LOG |
|
|
||||||
| `QF_DB_USER` | DB user | — |
|
|
||||||
| `QF_DB_PASSWORD` | DB password | — |
|
|
||||||
| `QF_JWT_SECRET` | JWT secret | — |
|
|
||||||
| `QF_SERVER_PORT` | HTTP server port | 8080 |
|
|
||||||
|
|
||||||
`QFS_BACKUP_DISABLE` accepts: `1`, `true`, `yes`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## CLI Flags
|
|
||||||
|
|
||||||
| Flag | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `-config <path>` | Path to config.yaml |
|
|
||||||
| `-localdb <path>` | Path to SQLite DB |
|
|
||||||
| `-reset-localdb` | Reset local DB (destructive!) |
|
|
||||||
| `-migrate` | Apply pending migrations and exit |
|
|
||||||
| `-version` | Print version and exit |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Installation and First Run
|
|
||||||
|
|
||||||
### Requirements
|
|
||||||
- Go 1.22 or higher
|
|
||||||
- MariaDB 11.x (or MySQL 8.x)
|
|
||||||
- ~50 MB disk space
|
|
||||||
|
|
||||||
### Steps
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Clone the repository
|
|
||||||
git clone <repo-url>
|
|
||||||
cd quoteforge
|
|
||||||
|
|
||||||
# 2. Apply migrations
|
|
||||||
go run ./cmd/qfs -migrate
|
|
||||||
|
|
||||||
# 3. Start
|
|
||||||
go run ./cmd/qfs
|
|
||||||
# or
|
|
||||||
make run
|
|
||||||
```
|
|
||||||
|
|
||||||
Application is available at: http://localhost:8080
|
|
||||||
|
|
||||||
On first run, `/setup` opens for configuring the MariaDB connection.
|
|
||||||
|
|
||||||
### OPS Project Migrator
|
|
||||||
|
|
||||||
Migrates quotes whose names start with `OPS-xxxx` (where `x` is a digit) into a project named `OPS-xxxx`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Preview first (always)
|
|
||||||
go run ./cmd/migrate_ops_projects
|
|
||||||
|
|
||||||
# Apply
|
|
||||||
go run ./cmd/migrate_ops_projects -apply
|
|
||||||
|
|
||||||
# Apply without interactive confirmation
|
|
||||||
go run ./cmd/migrate_ops_projects -apply -yes
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Docker
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build -t quoteforge .
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
# 06 — Backup
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Automatic rotating ZIP backup system for local data.
|
|
||||||
|
|
||||||
**What is included in each archive:**
|
|
||||||
- SQLite DB (`qfs.db`)
|
|
||||||
- SQLite sidecars (`qfs.db-wal`, `qfs.db-shm`) if present
|
|
||||||
- `config.yaml` if present
|
|
||||||
|
|
||||||
**Archive name format:** `qfs-backp-YYYY-MM-DD.zip`
|
|
||||||
|
|
||||||
**Retention policy:**
|
|
||||||
| Period | Keep |
|
|
||||||
|--------|------|
|
|
||||||
| Daily | 7 archives |
|
|
||||||
| Weekly | 4 archives |
|
|
||||||
| Monthly | 12 archives |
|
|
||||||
| Yearly | 10 archives |
|
|
||||||
|
|
||||||
**Directories:** `<backup root>/daily`, `/weekly`, `/monthly`, `/yearly`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
backup:
|
|
||||||
time: "00:00" # Trigger time in local time (HH:MM format)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Environment variables:**
|
|
||||||
- `QFS_BACKUP_DIR` — backup root directory (default: `<db dir>/backups`)
|
|
||||||
- `QFS_BACKUP_DISABLE` — disable backups (`1/true/yes`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Behavior
|
|
||||||
|
|
||||||
- **At startup:** if no backup exists for the current period, one is created immediately
|
|
||||||
- **Daily:** at the configured time, a new backup is created
|
|
||||||
- **Deduplication:** prevented via a `.period.json` marker file in each period directory
|
|
||||||
- **Rotation:** excess old archives are deleted automatically
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation
|
|
||||||
|
|
||||||
Module: `internal/appstate/backup.go`
|
|
||||||
|
|
||||||
Main function:
|
|
||||||
```go
|
|
||||||
func EnsureRotatingLocalBackup(dbPath, configPath string) ([]string, error)
|
|
||||||
```
|
|
||||||
|
|
||||||
Scheduler (in `main.go`):
|
|
||||||
```go
|
|
||||||
func startBackupScheduler(ctx context.Context, cfg *config.Config, dbPath, configPath string)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Config struct
|
|
||||||
|
|
||||||
```go
|
|
||||||
type BackupConfig struct {
|
|
||||||
Time string `yaml:"time"`
|
|
||||||
}
|
|
||||||
// Default: "00:00"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Notes
|
|
||||||
|
|
||||||
- `backup.time` is in **local time** without timezone offset parsing
|
|
||||||
- `.period.json` is the marker that prevents duplicate backups within the same period
|
|
||||||
- Archive filenames contain only the date; uniqueness is ensured by per-period directories + the period marker
|
|
||||||
- When changing naming or retention: update both the filename logic and the prune logic together
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Full Listing: `internal/appstate/backup.go`
|
|
||||||
|
|
||||||
```go
|
|
||||||
package appstate
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/zip"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type backupPeriod struct {
|
|
||||||
name string
|
|
||||||
retention int
|
|
||||||
key func(time.Time) string
|
|
||||||
date func(time.Time) string
|
|
||||||
}
|
|
||||||
|
|
||||||
var backupPeriods = []backupPeriod{
|
|
||||||
{
|
|
||||||
name: "daily",
|
|
||||||
retention: 7,
|
|
||||||
key: func(t time.Time) string { return t.Format("2006-01-02") },
|
|
||||||
date: func(t time.Time) string { return t.Format("2006-01-02") },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "weekly",
|
|
||||||
retention: 4,
|
|
||||||
key: func(t time.Time) string {
|
|
||||||
y, w := t.ISOWeek()
|
|
||||||
return fmt.Sprintf("%04d-W%02d", y, w)
|
|
||||||
},
|
|
||||||
date: func(t time.Time) string { return t.Format("2006-01-02") },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "monthly",
|
|
||||||
retention: 12,
|
|
||||||
key: func(t time.Time) string { return t.Format("2006-01") },
|
|
||||||
date: func(t time.Time) string { return t.Format("2006-01-02") },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "yearly",
|
|
||||||
retention: 10,
|
|
||||||
key: func(t time.Time) string { return t.Format("2006") },
|
|
||||||
date: func(t time.Time) string { return t.Format("2006-01-02") },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func EnsureRotatingLocalBackup(dbPath, configPath string) ([]string, error) {
|
|
||||||
if isBackupDisabled() || dbPath == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
root := resolveBackupRoot(dbPath)
|
|
||||||
now := time.Now()
|
|
||||||
created := make([]string, 0)
|
|
||||||
for _, period := range backupPeriods {
|
|
||||||
newFiles, err := ensurePeriodBackup(root, period, now, dbPath, configPath)
|
|
||||||
if err != nil {
|
|
||||||
return created, err
|
|
||||||
}
|
|
||||||
created = append(created, newFiles...)
|
|
||||||
}
|
|
||||||
return created, nil
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Full Listing: Scheduler Hook (`main.go`)
|
|
||||||
|
|
||||||
```go
|
|
||||||
func startBackupScheduler(ctx context.Context, cfg *config.Config, dbPath, configPath string) {
|
|
||||||
if cfg == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
hour, minute, err := parseBackupTime(cfg.Backup.Time)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("invalid backup time; using 00:00", "value", cfg.Backup.Time, "error", err)
|
|
||||||
hour, minute = 0, 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Startup check: create backup immediately if none exists for current periods
|
|
||||||
if created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath); backupErr != nil {
|
|
||||||
slog.Error("local backup failed", "error", backupErr)
|
|
||||||
} else {
|
|
||||||
for _, path := range created {
|
|
||||||
slog.Info("local backup completed", "archive", path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for {
|
|
||||||
next := nextBackupTime(time.Now(), hour, minute)
|
|
||||||
timer := time.NewTimer(time.Until(next))
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
timer.Stop()
|
|
||||||
return
|
|
||||||
case <-timer.C:
|
|
||||||
start := time.Now()
|
|
||||||
created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath)
|
|
||||||
duration := time.Since(start)
|
|
||||||
if backupErr != nil {
|
|
||||||
slog.Error("local backup failed", "error", backupErr, "duration", duration)
|
|
||||||
} else {
|
|
||||||
for _, path := range created {
|
|
||||||
slog.Info("local backup completed", "archive", path, "duration", duration)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseBackupTime(value string) (int, int, error) {
|
|
||||||
if strings.TrimSpace(value) == "" {
|
|
||||||
return 0, 0, fmt.Errorf("empty backup time")
|
|
||||||
}
|
|
||||||
parsed, err := time.Parse("15:04", value)
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, err
|
|
||||||
}
|
|
||||||
return parsed.Hour(), parsed.Minute(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func nextBackupTime(now time.Time, hour, minute int) time.Time {
|
|
||||||
target := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location())
|
|
||||||
if !now.Before(target) {
|
|
||||||
target = target.Add(24 * time.Hour)
|
|
||||||
}
|
|
||||||
return target
|
|
||||||
}
|
|
||||||
```
|
|
||||||
136
bible/07-dev.md
136
bible/07-dev.md
@@ -1,136 +0,0 @@
|
|||||||
# 07 — Development
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run (dev)
|
|
||||||
go run ./cmd/qfs
|
|
||||||
make run
|
|
||||||
|
|
||||||
# Build
|
|
||||||
make build-release # Optimized build with version info
|
|
||||||
CGO_ENABLED=0 go build -o bin/qfs ./cmd/qfs
|
|
||||||
|
|
||||||
# Cross-platform build
|
|
||||||
make build-all # Linux, macOS, Windows
|
|
||||||
make build-windows # Windows only
|
|
||||||
|
|
||||||
# Verification
|
|
||||||
go build ./cmd/qfs # Must compile without errors
|
|
||||||
go vet ./... # Linter
|
|
||||||
|
|
||||||
# Tests
|
|
||||||
go test ./...
|
|
||||||
make test
|
|
||||||
|
|
||||||
# Utilities
|
|
||||||
make install-hooks # Git hooks (block committing secrets)
|
|
||||||
make clean # Clean bin/
|
|
||||||
make help # All available commands
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Code Style
|
|
||||||
|
|
||||||
- **Formatting:** `gofmt` (mandatory)
|
|
||||||
- **Logging:** `slog` only (structured logging)
|
|
||||||
- **Errors:** explicit wrapping with context (`fmt.Errorf("context: %w", err)`)
|
|
||||||
- **Style:** no unnecessary abstractions; minimum code for the task
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Guardrails
|
|
||||||
|
|
||||||
### What Must Never Be Restored
|
|
||||||
|
|
||||||
The following components were **intentionally removed** and must not be brought back:
|
|
||||||
- cron jobs
|
|
||||||
- importer utility
|
|
||||||
- admin pricing UI/API
|
|
||||||
- alerts
|
|
||||||
- stock import
|
|
||||||
|
|
||||||
### Configuration Files
|
|
||||||
|
|
||||||
- `config.yaml` — runtime user file, **not stored in the repository**
|
|
||||||
- `config.example.yaml` — the only config template in the repo
|
|
||||||
|
|
||||||
### Sync and Local-First
|
|
||||||
|
|
||||||
- Any sync changes must preserve local-first behavior
|
|
||||||
- Local CRUD must not be blocked when MariaDB is unavailable
|
|
||||||
|
|
||||||
### Formats and UI
|
|
||||||
|
|
||||||
- **CSV export:** filename must use **project code** (`project.Code`), not project name
|
|
||||||
Format: `YYYY-MM-DD (ProjectCode) ConfigName Article.csv`
|
|
||||||
- **Breadcrumbs UI:** names longer than 16 characters must be truncated with an ellipsis
|
|
||||||
|
|
||||||
### Architecture Documentation
|
|
||||||
|
|
||||||
- **Every architectural decision must be recorded in `bible/`**
|
|
||||||
- The corresponding Bible file must be updated **in the same commit** as the code change
|
|
||||||
- On every user-requested commit, review and update the Bible in that same commit
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Common Tasks
|
|
||||||
|
|
||||||
### Add a Field to Configuration
|
|
||||||
|
|
||||||
1. Add the field to `LocalConfiguration` struct (`internal/models/`)
|
|
||||||
2. Add GORM tags for the DB column
|
|
||||||
3. Write a SQL migration (`migrations/`)
|
|
||||||
4. Update `ConfigurationToLocal` / `LocalToConfiguration` converters
|
|
||||||
5. Update API handlers and services
|
|
||||||
|
|
||||||
### Add a Field to Component
|
|
||||||
|
|
||||||
1. Add the field to `LocalComponent` struct (`internal/models/`)
|
|
||||||
2. Update the SQL query in `SyncComponents()`
|
|
||||||
3. Update the `componentRow` struct to match
|
|
||||||
4. Update converter functions
|
|
||||||
|
|
||||||
### Add a Pricelist Price Lookup
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Modern pattern
|
|
||||||
price, found := s.lookupPriceByPricelistID(pricelistID, lotName)
|
|
||||||
if found && price > 0 {
|
|
||||||
// use price
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Known Gotchas
|
|
||||||
|
|
||||||
1. **`CurrentPrice` removed from components** — any code using it will fail to compile
|
|
||||||
2. **`HasPrice` filter removed** — `component.go ListComponents` no longer supports this filter
|
|
||||||
3. **Quote calculation:** always offline-first (SQLite); online path is separate
|
|
||||||
4. **Items JSON:** prices are stored in the `items` field of the configuration, not fetched from components
|
|
||||||
5. **Migrations are additive:** already-applied migrations are skipped (checked by `id` in `local_schema_migrations`)
|
|
||||||
6. **`SyncedAt` removed:** last component sync time is now in `app_settings` (key=`last_component_sync`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Debugging Price Issues
|
|
||||||
|
|
||||||
**Problem: quote returns no prices**
|
|
||||||
1. Check that `pricelist_id` is set on the configuration
|
|
||||||
2. Check that pricelist items exist: `SELECT COUNT(*) FROM local_pricelist_items`
|
|
||||||
3. Check `lookupPriceByPricelistID()` in `quote.go`
|
|
||||||
4. Verify the correct source is used (estimate/warehouse/competitor)
|
|
||||||
|
|
||||||
**Problem: component sync not working**
|
|
||||||
1. Components sync as metadata only — no prices
|
|
||||||
2. Prices come via a separate pricelist sync
|
|
||||||
3. Check `SyncComponents()` and the MariaDB query
|
|
||||||
|
|
||||||
**Problem: configuration refresh does not update prices**
|
|
||||||
1. Refresh uses the latest estimate pricelist by default
|
|
||||||
2. Latest resolution ignores pricelists without items (`EXISTS local_pricelist_items`)
|
|
||||||
3. Old prices in `config.items` are preserved if a line item is not found in the pricelist
|
|
||||||
4. To force a pricelist update: set `configuration.pricelist_id`
|
|
||||||
5. In configurator, `Авто` must remain auto-mode (runtime resolved ID must not be persisted as explicit selection)
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
# QuoteForge Bible — Architectural Documentation
|
|
||||||
|
|
||||||
The single source of truth for architecture, schemas, and patterns.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Table of Contents
|
|
||||||
|
|
||||||
| File | Topic |
|
|
||||||
|------|-------|
|
|
||||||
| [01-overview.md](01-overview.md) | Product: purpose, features, tech stack, repository structure |
|
|
||||||
| [02-architecture.md](02-architecture.md) | Architecture: local-first, sync, pricing, versioning |
|
|
||||||
| [03-database.md](03-database.md) | DB schemas: SQLite + MariaDB, permissions, indexes |
|
|
||||||
| [04-api.md](04-api.md) | API endpoints and web routes |
|
|
||||||
| [05-config.md](05-config.md) | Configuration, environment variables, paths, installation |
|
|
||||||
| [06-backup.md](06-backup.md) | Backup: implementation, rotation policy |
|
|
||||||
| [07-dev.md](07-dev.md) | Development: commands, code style, guardrails |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Bible Rules
|
|
||||||
|
|
||||||
> **Every architectural decision must be recorded in the Bible.**
|
|
||||||
>
|
|
||||||
> Any change to DB schema, data access patterns, sync behavior, API contracts,
|
|
||||||
> configuration format, or any other system-level aspect — the corresponding `bible/` file
|
|
||||||
> **must be updated in the same commit** as the code.
|
|
||||||
>
|
|
||||||
> On every user-requested commit, the Bible must be reviewed and updated in that commit.
|
|
||||||
>
|
|
||||||
> The Bible is the single source of truth for architecture. Outdated documentation is worse than none.
|
|
||||||
|
|
||||||
> **Documentation language: English.**
|
|
||||||
>
|
|
||||||
> All files in `bible/` are written and updated **in English only**.
|
|
||||||
> Mixing languages is not allowed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick Reference
|
|
||||||
|
|
||||||
**Where is user data stored?**
|
|
||||||
SQLite → `~/Library/Application Support/QuoteForge/qfs.db` (macOS). MariaDB is sync-only.
|
|
||||||
|
|
||||||
**How to look up a price for a line item?**
|
|
||||||
`local_pricelist_items` → by `pricelist_id` from config + `lot_name`. Prices are **never** taken from `local_components`.
|
|
||||||
|
|
||||||
**Pre-commit check?**
|
|
||||||
`go build ./cmd/qfs && go vet ./...`
|
|
||||||
|
|
||||||
**What must never be restored?**
|
|
||||||
cron jobs, admin pricing, alerts, stock import, importer utility — all removed intentionally.
|
|
||||||
|
|
||||||
**Where is the release changelog?**
|
|
||||||
`releases/memory/v{major}.{minor}.{patch}.md`
|
|
||||||
84
cmd/cron/main.go
Normal file
84
cmd/cron/main.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
160
cmd/importer/main.go
Normal file
160
cmd/importer/main.go
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
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,6 +7,7 @@ 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"
|
||||||
@@ -15,6 +16,7 @@ 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)
|
||||||
@@ -26,6 +28,22 @@ 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)
|
||||||
@@ -33,28 +51,6 @@ 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
|
||||||
@@ -153,7 +149,23 @@ func main() {
|
|||||||
log.Printf(" Skipped: %d", skipped)
|
log.Printf(" Skipped: %d", skipped)
|
||||||
log.Printf(" Errors: %d", errors)
|
log.Printf(" Errors: %d", errors)
|
||||||
|
|
||||||
fmt.Println("\nDone! You can now run the server with: go run ./cmd/qfs")
|
// Save connection settings to local SQLite if not exists
|
||||||
|
if !local.HasSettings() {
|
||||||
|
log.Println("\nSaving connection settings to local SQLite...")
|
||||||
|
if err := local.SaveSettings(
|
||||||
|
cfg.Database.Host,
|
||||||
|
cfg.Database.Port,
|
||||||
|
cfg.Database.Name,
|
||||||
|
cfg.Database.User,
|
||||||
|
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")
|
||||||
}
|
}
|
||||||
|
|
||||||
func derefUint(v *uint) uint {
|
func derefUint(v *uint) uint {
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/appstate"
|
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"gorm.io/driver/mysql"
|
"gorm.io/driver/mysql"
|
||||||
@@ -39,29 +38,17 @@ type migrationAction struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
defaultLocalDBPath, err := appstate.ResolveDBPath("")
|
configPath := flag.String("config", "config.yaml", "path to config file")
|
||||||
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)")
|
apply := flag.Bool("apply", false, "apply migration (default is preview only)")
|
||||||
yes := flag.Bool("yes", false, "skip interactive confirmation (works only with -apply)")
|
yes := flag.Bool("yes", false, "skip interactive confirmation (works only with -apply)")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
local, err := localdb.New(*localDBPath)
|
cfg, err := config.Load(*configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to initialize local database: %v", err)
|
log.Fatalf("failed to load config: %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{
|
db, err := gorm.Open(mysql.Open(cfg.Database.DSN()), &gorm.Config{
|
||||||
Logger: logger.Default.LogMode(logger.Silent),
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -72,7 +59,7 @@ func main() {
|
|||||||
log.Fatalf("precheck failed: %v", err)
|
log.Fatalf("precheck failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
actions, existingProjects, err := buildPlan(db, dbUser)
|
actions, existingProjects, err := buildPlan(db, cfg.Database.User)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to build migration plan: %v", err)
|
log.Fatalf("failed to build migration plan: %v", err)
|
||||||
}
|
}
|
||||||
@@ -163,7 +150,7 @@ func buildPlan(db *gorm.DB, fallbackOwner string) ([]migrationAction, map[string
|
|||||||
}
|
}
|
||||||
for i := range projects {
|
for i := range projects {
|
||||||
p := projects[i]
|
p := projects[i]
|
||||||
existingProjects[projectKey(p.OwnerUsername, derefString(p.Name))] = &p
|
existingProjects[projectKey(p.OwnerUsername, p.Name)] = &p
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,8 +245,7 @@ func executePlan(db *gorm.DB, actions []migrationAction, existingProjects map[st
|
|||||||
project = &models.Project{
|
project = &models.Project{
|
||||||
UUID: uuid.NewString(),
|
UUID: uuid.NewString(),
|
||||||
OwnerUsername: action.OwnerUsername,
|
OwnerUsername: action.OwnerUsername,
|
||||||
Code: action.TargetProjectName,
|
Name: action.TargetProjectName,
|
||||||
Name: ptrString(action.TargetProjectName),
|
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
IsSystem: false,
|
IsSystem: false,
|
||||||
}
|
}
|
||||||
@@ -269,7 +255,7 @@ func executePlan(db *gorm.DB, actions []migrationAction, existingProjects map[st
|
|||||||
projectCache[key] = project
|
projectCache[key] = project
|
||||||
} else if !project.IsActive {
|
} else if !project.IsActive {
|
||||||
if err := tx.Model(&models.Project{}).Where("uuid = ?", project.UUID).Update("is_active", true).Error; err != nil {
|
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", derefString(project.Name), project.UUID, err)
|
return fmt.Errorf("reactivate project %s (%s): %w", project.Name, project.UUID, err)
|
||||||
}
|
}
|
||||||
project.IsActive = true
|
project.IsActive = true
|
||||||
}
|
}
|
||||||
@@ -295,14 +281,3 @@ func setKeys(set map[string]struct{}) []string {
|
|||||||
func projectKey(owner, name string) string {
|
func projectKey(owner, name string) string {
|
||||||
return owner + "||" + name
|
return owner + "||" + name
|
||||||
}
|
}
|
||||||
|
|
||||||
func derefString(value *string) string {
|
|
||||||
if value == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return *value
|
|
||||||
}
|
|
||||||
|
|
||||||
func ptrString(value string) *string {
|
|
||||||
return &value
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
762
cmd/qfs/main.go
762
cmd/qfs/main.go
File diff suppressed because it is too large
Load Diff
@@ -149,7 +149,7 @@ func TestProjectArchiveHidesConfigsAndCloneIntoProject(t *testing.T) {
|
|||||||
t.Fatalf("setup router: %v", err)
|
t.Fatalf("setup router: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"P1","code":"P1"}`)))
|
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"P1"}`)))
|
||||||
createProjectReq.Header.Set("Content-Type", "application/json")
|
createProjectReq.Header.Set("Content-Type", "application/json")
|
||||||
createProjectRec := httptest.NewRecorder()
|
createProjectRec := httptest.NewRecorder()
|
||||||
router.ServeHTTP(createProjectRec, createProjectReq)
|
router.ServeHTTP(createProjectRec, createProjectReq)
|
||||||
@@ -243,7 +243,7 @@ func TestConfigMoveToProjectEndpoint(t *testing.T) {
|
|||||||
t.Fatalf("setup router: %v", err)
|
t.Fatalf("setup router: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"Move Project","code":"MOVE"}`)))
|
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"Move Project"}`)))
|
||||||
createProjectReq.Header.Set("Content-Type", "application/json")
|
createProjectReq.Header.Set("Content-Type", "application/json")
|
||||||
createProjectRec := httptest.NewRecorder()
|
createProjectRec := httptest.NewRecorder()
|
||||||
router.ServeHTTP(createProjectRec, createProjectReq)
|
router.ServeHTTP(createProjectRec, createProjectReq)
|
||||||
|
|||||||
@@ -37,9 +37,6 @@ export:
|
|||||||
max_file_age: "1h"
|
max_file_age: "1h"
|
||||||
company_name: "Your Company Name"
|
company_name: "Your Company Name"
|
||||||
|
|
||||||
backup:
|
|
||||||
time: "00:00"
|
|
||||||
|
|
||||||
alerts:
|
alerts:
|
||||||
enabled: true
|
enabled: true
|
||||||
check_interval: "1h"
|
check_interval: "1h"
|
||||||
|
|||||||
15
crontab
Normal file
15
crontab
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# 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
|
||||||
BIN
dist/qfs-darwin-amd64
vendored
BIN
dist/qfs-darwin-amd64
vendored
Binary file not shown.
BIN
dist/qfs-darwin-arm64
vendored
BIN
dist/qfs-darwin-arm64
vendored
Binary file not shown.
BIN
dist/qfs-linux-amd64
vendored
BIN
dist/qfs-linux-amd64
vendored
Binary file not shown.
BIN
dist/qfs-windows-amd64.exe
vendored
BIN
dist/qfs-windows-amd64.exe
vendored
Binary file not shown.
@@ -1,273 +0,0 @@
|
|||||||
package appstate
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/zip"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type backupPeriod struct {
|
|
||||||
name string
|
|
||||||
retention int
|
|
||||||
key func(time.Time) string
|
|
||||||
date func(time.Time) string
|
|
||||||
}
|
|
||||||
|
|
||||||
var backupPeriods = []backupPeriod{
|
|
||||||
{
|
|
||||||
name: "daily",
|
|
||||||
retention: 7,
|
|
||||||
key: func(t time.Time) string {
|
|
||||||
return t.Format("2006-01-02")
|
|
||||||
},
|
|
||||||
date: func(t time.Time) string {
|
|
||||||
return t.Format("2006-01-02")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "weekly",
|
|
||||||
retention: 4,
|
|
||||||
key: func(t time.Time) string {
|
|
||||||
y, w := t.ISOWeek()
|
|
||||||
return fmt.Sprintf("%04d-W%02d", y, w)
|
|
||||||
},
|
|
||||||
date: func(t time.Time) string {
|
|
||||||
return t.Format("2006-01-02")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "monthly",
|
|
||||||
retention: 12,
|
|
||||||
key: func(t time.Time) string {
|
|
||||||
return t.Format("2006-01")
|
|
||||||
},
|
|
||||||
date: func(t time.Time) string {
|
|
||||||
return t.Format("2006-01-02")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "yearly",
|
|
||||||
retention: 10,
|
|
||||||
key: func(t time.Time) string {
|
|
||||||
return t.Format("2006")
|
|
||||||
},
|
|
||||||
date: func(t time.Time) string {
|
|
||||||
return t.Format("2006-01-02")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
envBackupDisable = "QFS_BACKUP_DISABLE"
|
|
||||||
envBackupDir = "QFS_BACKUP_DIR"
|
|
||||||
)
|
|
||||||
|
|
||||||
var backupNow = time.Now
|
|
||||||
|
|
||||||
// EnsureRotatingLocalBackup creates or refreshes daily/weekly/monthly/yearly backups
|
|
||||||
// for the local database and config. It keeps a limited number per period.
|
|
||||||
func EnsureRotatingLocalBackup(dbPath, configPath string) ([]string, error) {
|
|
||||||
if isBackupDisabled() {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if dbPath == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(dbPath); err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("stat db: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
root := resolveBackupRoot(dbPath)
|
|
||||||
now := backupNow()
|
|
||||||
|
|
||||||
created := make([]string, 0)
|
|
||||||
for _, period := range backupPeriods {
|
|
||||||
newFiles, err := ensurePeriodBackup(root, period, now, dbPath, configPath)
|
|
||||||
if err != nil {
|
|
||||||
return created, err
|
|
||||||
}
|
|
||||||
if len(newFiles) > 0 {
|
|
||||||
created = append(created, newFiles...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return created, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveBackupRoot(dbPath string) string {
|
|
||||||
if fromEnv := strings.TrimSpace(os.Getenv(envBackupDir)); fromEnv != "" {
|
|
||||||
return filepath.Clean(fromEnv)
|
|
||||||
}
|
|
||||||
return filepath.Join(filepath.Dir(dbPath), "backups")
|
|
||||||
}
|
|
||||||
|
|
||||||
func isBackupDisabled() bool {
|
|
||||||
val := strings.ToLower(strings.TrimSpace(os.Getenv(envBackupDisable)))
|
|
||||||
return val == "1" || val == "true" || val == "yes"
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensurePeriodBackup(root string, period backupPeriod, now time.Time, dbPath, configPath string) ([]string, error) {
|
|
||||||
key := period.key(now)
|
|
||||||
periodDir := filepath.Join(root, period.name)
|
|
||||||
if err := os.MkdirAll(periodDir, 0755); err != nil {
|
|
||||||
return nil, fmt.Errorf("create %s backup dir: %w", period.name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasBackupForKey(periodDir, key) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
archiveName := fmt.Sprintf("qfs-backp-%s.zip", period.date(now))
|
|
||||||
archivePath := filepath.Join(periodDir, archiveName)
|
|
||||||
|
|
||||||
if err := createBackupArchive(archivePath, dbPath, configPath); err != nil {
|
|
||||||
return nil, fmt.Errorf("create %s backup archive: %w", period.name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := writePeriodMarker(periodDir, key); err != nil {
|
|
||||||
return []string{archivePath}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := pruneOldBackups(periodDir, period.retention); err != nil {
|
|
||||||
return []string{archivePath}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return []string{archivePath}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasBackupForKey(periodDir, key string) bool {
|
|
||||||
marker := periodMarker{Key: ""}
|
|
||||||
data, err := os.ReadFile(periodMarkerPath(periodDir))
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(data, &marker); err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return marker.Key == key
|
|
||||||
}
|
|
||||||
|
|
||||||
type periodMarker struct {
|
|
||||||
Key string `json:"key"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func periodMarkerPath(periodDir string) string {
|
|
||||||
return filepath.Join(periodDir, ".period.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
func writePeriodMarker(periodDir, key string) error {
|
|
||||||
data, err := json.MarshalIndent(periodMarker{Key: key}, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return os.WriteFile(periodMarkerPath(periodDir), data, 0644)
|
|
||||||
}
|
|
||||||
|
|
||||||
func pruneOldBackups(periodDir string, keep int) error {
|
|
||||||
entries, err := os.ReadDir(periodDir)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("read backups dir: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
files := make([]os.DirEntry, 0, len(entries))
|
|
||||||
for _, entry := range entries {
|
|
||||||
if entry.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.HasSuffix(entry.Name(), ".zip") {
|
|
||||||
files = append(files, entry)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(files) <= keep {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Slice(files, func(i, j int) bool {
|
|
||||||
infoI, errI := files[i].Info()
|
|
||||||
infoJ, errJ := files[j].Info()
|
|
||||||
if errI != nil || errJ != nil {
|
|
||||||
return files[i].Name() < files[j].Name()
|
|
||||||
}
|
|
||||||
return infoI.ModTime().Before(infoJ.ModTime())
|
|
||||||
})
|
|
||||||
|
|
||||||
for i := 0; i < len(files)-keep; i++ {
|
|
||||||
path := filepath.Join(periodDir, files[i].Name())
|
|
||||||
if err := os.Remove(path); err != nil {
|
|
||||||
return fmt.Errorf("remove old backup %s: %w", path, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func createBackupArchive(destPath, dbPath, configPath string) error {
|
|
||||||
file, err := os.Create(destPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
zipWriter := zip.NewWriter(file)
|
|
||||||
if err := addZipFile(zipWriter, dbPath); err != nil {
|
|
||||||
_ = zipWriter.Close()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_ = addZipOptionalFile(zipWriter, dbPath+"-wal")
|
|
||||||
_ = addZipOptionalFile(zipWriter, dbPath+"-shm")
|
|
||||||
|
|
||||||
if strings.TrimSpace(configPath) != "" {
|
|
||||||
_ = addZipOptionalFile(zipWriter, configPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := zipWriter.Close(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return file.Sync()
|
|
||||||
}
|
|
||||||
|
|
||||||
func addZipOptionalFile(writer *zip.Writer, path string) error {
|
|
||||||
if _, err := os.Stat(path); err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return addZipFile(writer, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
func addZipFile(writer *zip.Writer, path string) error {
|
|
||||||
in, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer in.Close()
|
|
||||||
|
|
||||||
info, err := in.Stat()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
header, err := zip.FileInfoHeader(info)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
header.Name = filepath.Base(path)
|
|
||||||
header.Method = zip.Deflate
|
|
||||||
|
|
||||||
out, err := writer.CreateHeader(header)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = io.Copy(out, in)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
package appstate
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestEnsureRotatingLocalBackupCreatesAndRotates(t *testing.T) {
|
|
||||||
temp := t.TempDir()
|
|
||||||
dbPath := filepath.Join(temp, "qfs.db")
|
|
||||||
cfgPath := filepath.Join(temp, "config.yaml")
|
|
||||||
|
|
||||||
if err := os.WriteFile(dbPath, []byte("db"), 0644); err != nil {
|
|
||||||
t.Fatalf("write db: %v", err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil {
|
|
||||||
t.Fatalf("write config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
prevNow := backupNow
|
|
||||||
defer func() { backupNow = prevNow }()
|
|
||||||
backupNow = func() time.Time { return time.Date(2026, 2, 11, 10, 0, 0, 0, time.UTC) }
|
|
||||||
|
|
||||||
created, err := EnsureRotatingLocalBackup(dbPath, cfgPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("backup: %v", err)
|
|
||||||
}
|
|
||||||
if len(created) == 0 {
|
|
||||||
t.Fatalf("expected backup to be created")
|
|
||||||
}
|
|
||||||
|
|
||||||
dailyArchive := filepath.Join(temp, "backups", "daily", "qfs-backp-2026-02-11.zip")
|
|
||||||
if _, err := os.Stat(dailyArchive); err != nil {
|
|
||||||
t.Fatalf("daily archive missing: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
backupNow = func() time.Time { return time.Date(2026, 2, 12, 10, 0, 0, 0, time.UTC) }
|
|
||||||
created, err = EnsureRotatingLocalBackup(dbPath, cfgPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("backup rotate: %v", err)
|
|
||||||
}
|
|
||||||
if len(created) == 0 {
|
|
||||||
t.Fatalf("expected backup to be created for new day")
|
|
||||||
}
|
|
||||||
|
|
||||||
dailyArchive = filepath.Join(temp, "backups", "daily", "qfs-backp-2026-02-12.zip")
|
|
||||||
if _, err := os.Stat(dailyArchive); err != nil {
|
|
||||||
t.Fatalf("daily archive missing after rotate: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnsureRotatingLocalBackupEnvControls(t *testing.T) {
|
|
||||||
temp := t.TempDir()
|
|
||||||
dbPath := filepath.Join(temp, "qfs.db")
|
|
||||||
cfgPath := filepath.Join(temp, "config.yaml")
|
|
||||||
|
|
||||||
if err := os.WriteFile(dbPath, []byte("db"), 0644); err != nil {
|
|
||||||
t.Fatalf("write db: %v", err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil {
|
|
||||||
t.Fatalf("write config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
backupRoot := filepath.Join(temp, "custom_backups")
|
|
||||||
t.Setenv(envBackupDir, backupRoot)
|
|
||||||
|
|
||||||
if _, err := EnsureRotatingLocalBackup(dbPath, cfgPath); err != nil {
|
|
||||||
t.Fatalf("backup with env: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(filepath.Join(backupRoot, "daily", "meta.json")); err != nil {
|
|
||||||
t.Fatalf("expected backup in custom dir: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Setenv(envBackupDisable, "1")
|
|
||||||
if _, err := EnsureRotatingLocalBackup(dbPath, cfgPath); err != nil {
|
|
||||||
t.Fatalf("backup disabled: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(filepath.Join(backupRoot, "daily", "meta.json")); err != nil {
|
|
||||||
t.Fatalf("backup should remain from previous run: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -56,25 +55,6 @@ 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.
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
package article
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ErrMissingCategoryForLot is returned when a lot has no category in local_pricelist_items.lot_category.
|
|
||||||
var ErrMissingCategoryForLot = errors.New("missing_category_for_lot")
|
|
||||||
|
|
||||||
type MissingCategoryForLotError struct {
|
|
||||||
LotName string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *MissingCategoryForLotError) Error() string {
|
|
||||||
if e == nil || strings.TrimSpace(e.LotName) == "" {
|
|
||||||
return ErrMissingCategoryForLot.Error()
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%s: %s", ErrMissingCategoryForLot.Error(), e.LotName)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *MissingCategoryForLotError) Unwrap() error {
|
|
||||||
return ErrMissingCategoryForLot
|
|
||||||
}
|
|
||||||
|
|
||||||
type Group string
|
|
||||||
|
|
||||||
const (
|
|
||||||
GroupCPU Group = "CPU"
|
|
||||||
GroupMEM Group = "MEM"
|
|
||||||
GroupGPU Group = "GPU"
|
|
||||||
GroupDISK Group = "DISK"
|
|
||||||
GroupNET Group = "NET"
|
|
||||||
GroupPSU Group = "PSU"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GroupForLotCategory maps pricelist lot_category codes into article groups.
|
|
||||||
// Unknown/unrelated categories return ok=false.
|
|
||||||
func GroupForLotCategory(cat string) (group Group, ok bool) {
|
|
||||||
c := strings.ToUpper(strings.TrimSpace(cat))
|
|
||||||
switch c {
|
|
||||||
case "CPU":
|
|
||||||
return GroupCPU, true
|
|
||||||
case "MEM":
|
|
||||||
return GroupMEM, true
|
|
||||||
case "GPU":
|
|
||||||
return GroupGPU, true
|
|
||||||
case "M2", "SSD", "HDD", "EDSFF", "HHHL":
|
|
||||||
return GroupDISK, true
|
|
||||||
case "NIC", "HCA", "DPU":
|
|
||||||
return GroupNET, true
|
|
||||||
case "HBA":
|
|
||||||
return GroupNET, true
|
|
||||||
case "PSU", "PS":
|
|
||||||
return GroupPSU, true
|
|
||||||
default:
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResolveLotCategoriesStrict resolves categories for lotNames using local_pricelist_items.lot_category
|
|
||||||
// for a given server pricelist id. If any lot is missing or has empty category, returns an error.
|
|
||||||
func ResolveLotCategoriesStrict(local *localdb.LocalDB, serverPricelistID uint, lotNames []string) (map[string]string, error) {
|
|
||||||
if local == nil {
|
|
||||||
return nil, fmt.Errorf("local db is nil")
|
|
||||||
}
|
|
||||||
cats, err := local.GetLocalLotCategoriesByServerPricelistID(serverPricelistID, lotNames)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
missing := make([]string, 0)
|
|
||||||
for _, lot := range lotNames {
|
|
||||||
cat := strings.TrimSpace(cats[lot])
|
|
||||||
if cat == "" {
|
|
||||||
missing = append(missing, lot)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
cats[lot] = cat
|
|
||||||
}
|
|
||||||
if len(missing) > 0 {
|
|
||||||
fallback, err := local.GetLocalComponentCategoriesByLotNames(missing)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for _, lot := range missing {
|
|
||||||
if cat := strings.TrimSpace(fallback[lot]); cat != "" {
|
|
||||||
cats[lot] = cat
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, lot := range missing {
|
|
||||||
if strings.TrimSpace(cats[lot]) == "" {
|
|
||||||
return nil, &MissingCategoryForLotError{LotName: lot}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cats, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// NormalizeServerModel produces a stable article segment for the server model.
|
|
||||||
func NormalizeServerModel(model string) string {
|
|
||||||
trimmed := strings.TrimSpace(model)
|
|
||||||
if trimmed == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
upper := strings.ToUpper(trimmed)
|
|
||||||
var b strings.Builder
|
|
||||||
for _, r := range upper {
|
|
||||||
if r >= 'A' && r <= 'Z' {
|
|
||||||
b.WriteRune(r)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if r >= '0' && r <= '9' {
|
|
||||||
b.WriteRune(r)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if r == '.' {
|
|
||||||
b.WriteRune(r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
package article
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestResolveLotCategoriesStrict_MissingCategoryReturnsError(t *testing.T) {
|
|
||||||
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("init local db: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = local.Close() })
|
|
||||||
|
|
||||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
|
||||||
ServerID: 1,
|
|
||||||
Source: "estimate",
|
|
||||||
Version: "S-2026-02-11-001",
|
|
||||||
Name: "test",
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
SyncedAt: time.Now(),
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("save local pricelist: %v", err)
|
|
||||||
}
|
|
||||||
localPL, err := local.GetLocalPricelistByServerID(1)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get local pricelist: %v", err)
|
|
||||||
}
|
|
||||||
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
|
||||||
{PricelistID: localPL.ID, LotName: "CPU_A", LotCategory: "", Price: 10},
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("save local items: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = ResolveLotCategoriesStrict(local, 1, []string{"CPU_A"})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("expected error")
|
|
||||||
}
|
|
||||||
if !errors.Is(err, ErrMissingCategoryForLot) {
|
|
||||||
t.Fatalf("expected ErrMissingCategoryForLot, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveLotCategoriesStrict_FallbackToLocalComponents(t *testing.T) {
|
|
||||||
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("init local db: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = local.Close() })
|
|
||||||
|
|
||||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
|
||||||
ServerID: 2,
|
|
||||||
Source: "estimate",
|
|
||||||
Version: "S-2026-02-11-002",
|
|
||||||
Name: "test",
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
SyncedAt: time.Now(),
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("save local pricelist: %v", err)
|
|
||||||
}
|
|
||||||
localPL, err := local.GetLocalPricelistByServerID(2)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get local pricelist: %v", err)
|
|
||||||
}
|
|
||||||
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
|
||||||
{PricelistID: localPL.ID, LotName: "CPU_B", LotCategory: "", Price: 10},
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("save local items: %v", err)
|
|
||||||
}
|
|
||||||
if err := local.DB().Create(&localdb.LocalComponent{
|
|
||||||
LotName: "CPU_B",
|
|
||||||
Category: "CPU",
|
|
||||||
LotDescription: "cpu",
|
|
||||||
}).Error; err != nil {
|
|
||||||
t.Fatalf("save local components: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cats, err := ResolveLotCategoriesStrict(local, 2, []string{"CPU_B"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected fallback, got error: %v", err)
|
|
||||||
}
|
|
||||||
if cats["CPU_B"] != "CPU" {
|
|
||||||
t.Fatalf("expected CPU, got %q", cats["CPU_B"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGroupForLotCategory(t *testing.T) {
|
|
||||||
if g, ok := GroupForLotCategory("cpu"); !ok || g != GroupCPU {
|
|
||||||
t.Fatalf("expected cpu -> GroupCPU")
|
|
||||||
}
|
|
||||||
if g, ok := GroupForLotCategory("SFP"); ok || g != "" {
|
|
||||||
t.Fatalf("expected SFP to be excluded")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,602 +0,0 @@
|
|||||||
package article
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
type BuildOptions struct {
|
|
||||||
ServerModel string
|
|
||||||
SupportCode string
|
|
||||||
ServerPricelist *uint
|
|
||||||
}
|
|
||||||
|
|
||||||
type BuildResult struct {
|
|
||||||
Article string
|
|
||||||
Warnings []string
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
reMemGiB = regexp.MustCompile(`(?i)(\d+)\s*(GB|G)`)
|
|
||||||
reMemTiB = regexp.MustCompile(`(?i)(\d+)\s*(TB|T)`)
|
|
||||||
reCapacityT = regexp.MustCompile(`(?i)(\d+(?:[.,]\d+)?)T`)
|
|
||||||
reCapacityG = regexp.MustCompile(`(?i)(\d+(?:[.,]\d+)?)G`)
|
|
||||||
rePortSpeed = regexp.MustCompile(`(?i)(\d+)p(\d+)(GbE|G)`)
|
|
||||||
rePortFC = regexp.MustCompile(`(?i)(\d+)pFC(\d+)`)
|
|
||||||
reWatts = regexp.MustCompile(`(?i)(\d{3,5})\s*W`)
|
|
||||||
)
|
|
||||||
|
|
||||||
func Build(local *localdb.LocalDB, items []models.ConfigItem, opts BuildOptions) (BuildResult, error) {
|
|
||||||
segments := make([]string, 0, 8)
|
|
||||||
warnings := make([]string, 0)
|
|
||||||
|
|
||||||
model := NormalizeServerModel(opts.ServerModel)
|
|
||||||
if model == "" {
|
|
||||||
return BuildResult{}, fmt.Errorf("server_model required")
|
|
||||||
}
|
|
||||||
segments = append(segments, model)
|
|
||||||
|
|
||||||
lotNames := make([]string, 0, len(items))
|
|
||||||
for _, it := range items {
|
|
||||||
lotNames = append(lotNames, it.LotName)
|
|
||||||
}
|
|
||||||
|
|
||||||
if opts.ServerPricelist == nil || *opts.ServerPricelist == 0 {
|
|
||||||
return BuildResult{}, fmt.Errorf("pricelist_id required for article")
|
|
||||||
}
|
|
||||||
|
|
||||||
cats, err := ResolveLotCategoriesStrict(local, *opts.ServerPricelist, lotNames)
|
|
||||||
if err != nil {
|
|
||||||
return BuildResult{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
cpuSeg := buildCPUSegment(items, cats)
|
|
||||||
if cpuSeg != "" {
|
|
||||||
segments = append(segments, cpuSeg)
|
|
||||||
}
|
|
||||||
memSeg, memWarn := buildMemSegment(items, cats)
|
|
||||||
if memWarn != "" {
|
|
||||||
warnings = append(warnings, memWarn)
|
|
||||||
}
|
|
||||||
if memSeg != "" {
|
|
||||||
segments = append(segments, memSeg)
|
|
||||||
}
|
|
||||||
gpuSeg := buildGPUSegment(items, cats)
|
|
||||||
if gpuSeg != "" {
|
|
||||||
segments = append(segments, gpuSeg)
|
|
||||||
}
|
|
||||||
diskSeg, diskWarn := buildDiskSegment(items, cats)
|
|
||||||
if diskWarn != "" {
|
|
||||||
warnings = append(warnings, diskWarn)
|
|
||||||
}
|
|
||||||
if diskSeg != "" {
|
|
||||||
segments = append(segments, diskSeg)
|
|
||||||
}
|
|
||||||
netSeg, netWarn := buildNetSegment(items, cats)
|
|
||||||
if netWarn != "" {
|
|
||||||
warnings = append(warnings, netWarn)
|
|
||||||
}
|
|
||||||
if netSeg != "" {
|
|
||||||
segments = append(segments, netSeg)
|
|
||||||
}
|
|
||||||
psuSeg, psuWarn := buildPSUSegment(items, cats)
|
|
||||||
if psuWarn != "" {
|
|
||||||
warnings = append(warnings, psuWarn)
|
|
||||||
}
|
|
||||||
if psuSeg != "" {
|
|
||||||
segments = append(segments, psuSeg)
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.TrimSpace(opts.SupportCode) != "" {
|
|
||||||
code := strings.TrimSpace(opts.SupportCode)
|
|
||||||
if !isSupportCodeValid(code) {
|
|
||||||
return BuildResult{}, fmt.Errorf("invalid_support_code")
|
|
||||||
}
|
|
||||||
segments = append(segments, code)
|
|
||||||
}
|
|
||||||
|
|
||||||
article := strings.Join(segments, "-")
|
|
||||||
if len([]rune(article)) > 80 {
|
|
||||||
article = compressArticle(segments)
|
|
||||||
warnings = append(warnings, "compressed")
|
|
||||||
}
|
|
||||||
if len([]rune(article)) > 80 {
|
|
||||||
return BuildResult{}, fmt.Errorf("article_overflow")
|
|
||||||
}
|
|
||||||
|
|
||||||
return BuildResult{Article: article, Warnings: warnings}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func isSupportCodeValid(code string) bool {
|
|
||||||
if len(code) < 3 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if !strings.Contains(code, "y") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
parts := strings.Split(code, "y")
|
|
||||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, r := range parts[0] {
|
|
||||||
if r < '0' || r > '9' {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
switch parts[1] {
|
|
||||||
case "W", "B", "S", "P":
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildCPUSegment(items []models.ConfigItem, cats map[string]string) string {
|
|
||||||
type agg struct {
|
|
||||||
qty int
|
|
||||||
}
|
|
||||||
models := map[string]*agg{}
|
|
||||||
for _, it := range items {
|
|
||||||
group, ok := GroupForLotCategory(cats[it.LotName])
|
|
||||||
if !ok || group != GroupCPU {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
model := parseCPUModel(it.LotName)
|
|
||||||
if model == "" {
|
|
||||||
model = "UNK"
|
|
||||||
}
|
|
||||||
if _, ok := models[model]; !ok {
|
|
||||||
models[model] = &agg{}
|
|
||||||
}
|
|
||||||
models[model].qty += it.Quantity
|
|
||||||
}
|
|
||||||
if len(models) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
parts := make([]string, 0, len(models))
|
|
||||||
for model, a := range models {
|
|
||||||
parts = append(parts, fmt.Sprintf("%dx%s", a.qty, model))
|
|
||||||
}
|
|
||||||
sort.Strings(parts)
|
|
||||||
return strings.Join(parts, "+")
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildMemSegment(items []models.ConfigItem, cats map[string]string) (string, string) {
|
|
||||||
totalGiB := 0
|
|
||||||
for _, it := range items {
|
|
||||||
group, ok := GroupForLotCategory(cats[it.LotName])
|
|
||||||
if !ok || group != GroupMEM {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
per := parseMemGiB(it.LotName)
|
|
||||||
if per <= 0 {
|
|
||||||
return "", "mem_unknown"
|
|
||||||
}
|
|
||||||
totalGiB += per * it.Quantity
|
|
||||||
}
|
|
||||||
if totalGiB == 0 {
|
|
||||||
return "", ""
|
|
||||||
}
|
|
||||||
if totalGiB%1024 == 0 {
|
|
||||||
return fmt.Sprintf("%dT", totalGiB/1024), ""
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%dG", totalGiB), ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildGPUSegment(items []models.ConfigItem, cats map[string]string) string {
|
|
||||||
models := map[string]int{}
|
|
||||||
for _, it := range items {
|
|
||||||
group, ok := GroupForLotCategory(cats[it.LotName])
|
|
||||||
if !ok || group != GroupGPU {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
model := parseGPUModel(it.LotName)
|
|
||||||
if model == "" {
|
|
||||||
model = "UNK"
|
|
||||||
}
|
|
||||||
models[model] += it.Quantity
|
|
||||||
}
|
|
||||||
if len(models) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
parts := make([]string, 0, len(models))
|
|
||||||
for model, qty := range models {
|
|
||||||
parts = append(parts, fmt.Sprintf("%dx%s", qty, model))
|
|
||||||
}
|
|
||||||
sort.Strings(parts)
|
|
||||||
return strings.Join(parts, "+")
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildDiskSegment(items []models.ConfigItem, cats map[string]string) (string, string) {
|
|
||||||
type key struct {
|
|
||||||
t string
|
|
||||||
c string
|
|
||||||
}
|
|
||||||
groupQty := map[key]int{}
|
|
||||||
warn := ""
|
|
||||||
for _, it := range items {
|
|
||||||
group, ok := GroupForLotCategory(cats[it.LotName])
|
|
||||||
if !ok || group != GroupDISK {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
capToken := parseCapacity(it.LotName)
|
|
||||||
if capToken == "" {
|
|
||||||
warn = "disk_unknown"
|
|
||||||
}
|
|
||||||
typeCode := diskTypeCode(cats[it.LotName], it.LotName)
|
|
||||||
k := key{t: typeCode, c: capToken}
|
|
||||||
groupQty[k] += it.Quantity
|
|
||||||
}
|
|
||||||
if len(groupQty) == 0 {
|
|
||||||
return "", ""
|
|
||||||
}
|
|
||||||
parts := make([]string, 0, len(groupQty))
|
|
||||||
for k, qty := range groupQty {
|
|
||||||
if k.c == "" {
|
|
||||||
parts = append(parts, fmt.Sprintf("%dx%s", qty, k.t))
|
|
||||||
} else {
|
|
||||||
parts = append(parts, fmt.Sprintf("%dx%s%s", qty, k.c, k.t))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(parts)
|
|
||||||
return strings.Join(parts, "+"), warn
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildNetSegment(items []models.ConfigItem, cats map[string]string) (string, string) {
|
|
||||||
groupQty := map[string]int{}
|
|
||||||
warn := ""
|
|
||||||
for _, it := range items {
|
|
||||||
group, ok := GroupForLotCategory(cats[it.LotName])
|
|
||||||
if !ok || group != GroupNET {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
profile := parsePortSpeed(it.LotName)
|
|
||||||
if profile == "" {
|
|
||||||
profile = "UNKNET"
|
|
||||||
warn = "net_unknown"
|
|
||||||
}
|
|
||||||
groupQty[profile] += it.Quantity
|
|
||||||
}
|
|
||||||
if len(groupQty) == 0 {
|
|
||||||
return "", ""
|
|
||||||
}
|
|
||||||
parts := make([]string, 0, len(groupQty))
|
|
||||||
for profile, qty := range groupQty {
|
|
||||||
parts = append(parts, fmt.Sprintf("%dx%s", qty, profile))
|
|
||||||
}
|
|
||||||
sort.Strings(parts)
|
|
||||||
return strings.Join(parts, "+"), warn
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildPSUSegment(items []models.ConfigItem, cats map[string]string) (string, string) {
|
|
||||||
groupQty := map[string]int{}
|
|
||||||
warn := ""
|
|
||||||
for _, it := range items {
|
|
||||||
group, ok := GroupForLotCategory(cats[it.LotName])
|
|
||||||
if !ok || group != GroupPSU {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rating := parseWatts(it.LotName)
|
|
||||||
if rating == "" {
|
|
||||||
rating = "UNKPSU"
|
|
||||||
warn = "psu_unknown"
|
|
||||||
}
|
|
||||||
groupQty[rating] += it.Quantity
|
|
||||||
}
|
|
||||||
if len(groupQty) == 0 {
|
|
||||||
return "", ""
|
|
||||||
}
|
|
||||||
parts := make([]string, 0, len(groupQty))
|
|
||||||
for rating, qty := range groupQty {
|
|
||||||
parts = append(parts, fmt.Sprintf("%dx%s", qty, rating))
|
|
||||||
}
|
|
||||||
sort.Strings(parts)
|
|
||||||
return strings.Join(parts, "+"), warn
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeModelToken(lotName string) string {
|
|
||||||
if idx := strings.Index(lotName, "_"); idx >= 0 && idx+1 < len(lotName) {
|
|
||||||
lotName = lotName[idx+1:]
|
|
||||||
}
|
|
||||||
parts := strings.Split(lotName, "_")
|
|
||||||
token := parts[len(parts)-1]
|
|
||||||
return strings.ToUpper(strings.TrimSpace(token))
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseCPUModel(lotName string) string {
|
|
||||||
parts := strings.Split(lotName, "_")
|
|
||||||
if len(parts) >= 2 {
|
|
||||||
last := strings.ToUpper(strings.TrimSpace(parts[len(parts)-1]))
|
|
||||||
if last != "" {
|
|
||||||
return last
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return normalizeModelToken(lotName)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseGPUModel(lotName string) string {
|
|
||||||
upper := strings.ToUpper(lotName)
|
|
||||||
if idx := strings.Index(upper, "GPU_"); idx >= 0 {
|
|
||||||
upper = upper[idx+4:]
|
|
||||||
}
|
|
||||||
parts := strings.Split(upper, "_")
|
|
||||||
model := ""
|
|
||||||
mem := ""
|
|
||||||
for i, p := range parts {
|
|
||||||
if p == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch p {
|
|
||||||
case "NV", "NVIDIA", "AMD", "RADEON", "PCIE", "PCI", "SXM", "SXMX":
|
|
||||||
continue
|
|
||||||
default:
|
|
||||||
if strings.Contains(p, "GB") {
|
|
||||||
mem = p
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if model == "" && (i > 0) {
|
|
||||||
model = p
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if model != "" && mem != "" {
|
|
||||||
return model + "_" + mem
|
|
||||||
}
|
|
||||||
if model != "" {
|
|
||||||
return model
|
|
||||||
}
|
|
||||||
return normalizeModelToken(lotName)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseMemGiB(lotName string) int {
|
|
||||||
if m := reMemTiB.FindStringSubmatch(lotName); len(m) == 3 {
|
|
||||||
return atoi(m[1]) * 1024
|
|
||||||
}
|
|
||||||
if m := reMemGiB.FindStringSubmatch(lotName); len(m) == 3 {
|
|
||||||
return atoi(m[1])
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseCapacity(lotName string) string {
|
|
||||||
if m := reCapacityT.FindStringSubmatch(lotName); len(m) == 2 {
|
|
||||||
return normalizeTToken(strings.ReplaceAll(m[1], ",", ".")) + "T"
|
|
||||||
}
|
|
||||||
if m := reCapacityG.FindStringSubmatch(lotName); len(m) == 2 {
|
|
||||||
return normalizeNumberToken(strings.ReplaceAll(m[1], ",", ".")) + "G"
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func diskTypeCode(cat string, lotName string) string {
|
|
||||||
c := strings.ToUpper(strings.TrimSpace(cat))
|
|
||||||
if c == "M2" {
|
|
||||||
return "M2"
|
|
||||||
}
|
|
||||||
upper := strings.ToUpper(lotName)
|
|
||||||
if strings.Contains(upper, "NVME") {
|
|
||||||
return "NV"
|
|
||||||
}
|
|
||||||
if strings.Contains(upper, "SAS") {
|
|
||||||
return "SAS"
|
|
||||||
}
|
|
||||||
if strings.Contains(upper, "SATA") {
|
|
||||||
return "SAT"
|
|
||||||
}
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
func parsePortSpeed(lotName string) string {
|
|
||||||
if m := rePortSpeed.FindStringSubmatch(lotName); len(m) == 4 {
|
|
||||||
return fmt.Sprintf("%sp%sG", m[1], m[2])
|
|
||||||
}
|
|
||||||
if m := rePortFC.FindStringSubmatch(lotName); len(m) == 3 {
|
|
||||||
return fmt.Sprintf("%spFC%s", m[1], m[2])
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseWatts(lotName string) string {
|
|
||||||
if m := reWatts.FindStringSubmatch(lotName); len(m) == 2 {
|
|
||||||
w := atoi(m[1])
|
|
||||||
if w >= 1000 {
|
|
||||||
kw := fmt.Sprintf("%.1f", float64(w)/1000.0)
|
|
||||||
kw = strings.TrimSuffix(kw, ".0")
|
|
||||||
return fmt.Sprintf("%skW", kw)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%dW", w)
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeNumberToken(raw string) string {
|
|
||||||
raw = strings.TrimSpace(raw)
|
|
||||||
raw = strings.TrimLeft(raw, "0")
|
|
||||||
if raw == "" || raw[0] == '.' {
|
|
||||||
raw = "0" + raw
|
|
||||||
}
|
|
||||||
return raw
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeTToken(raw string) string {
|
|
||||||
raw = normalizeNumberToken(raw)
|
|
||||||
parts := strings.SplitN(raw, ".", 2)
|
|
||||||
intPart := parts[0]
|
|
||||||
frac := ""
|
|
||||||
if len(parts) == 2 {
|
|
||||||
frac = parts[1]
|
|
||||||
}
|
|
||||||
if frac == "" {
|
|
||||||
frac = "0"
|
|
||||||
}
|
|
||||||
if len(intPart) >= 2 {
|
|
||||||
return intPart + "." + frac
|
|
||||||
}
|
|
||||||
if len(frac) > 1 {
|
|
||||||
frac = frac[:1]
|
|
||||||
}
|
|
||||||
return intPart + "." + frac
|
|
||||||
}
|
|
||||||
|
|
||||||
func atoi(v string) int {
|
|
||||||
n := 0
|
|
||||||
for _, r := range v {
|
|
||||||
if r < '0' || r > '9' {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
n = n*10 + int(r-'0')
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
func compressArticle(segments []string) string {
|
|
||||||
if len(segments) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
normalized := make([]string, 0, len(segments))
|
|
||||||
for _, s := range segments {
|
|
||||||
normalized = append(normalized, strings.ReplaceAll(s, "GbE", "G"))
|
|
||||||
}
|
|
||||||
segments = normalized
|
|
||||||
article := strings.Join(segments, "-")
|
|
||||||
if len([]rune(article)) <= 80 {
|
|
||||||
return article
|
|
||||||
}
|
|
||||||
|
|
||||||
// segment order: model, cpu, mem, gpu, disk, net, psu, support
|
|
||||||
index := func(i int) (int, bool) {
|
|
||||||
if i >= 0 && i < len(segments) {
|
|
||||||
return i, true
|
|
||||||
}
|
|
||||||
return -1, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1) remove PSU
|
|
||||||
if i, ok := index(6); ok {
|
|
||||||
segments = append(segments[:i], segments[i+1:]...)
|
|
||||||
article = strings.Join(segments, "-")
|
|
||||||
if len([]rune(article)) <= 80 {
|
|
||||||
return article
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) compress NET/HBA/HCA
|
|
||||||
if i, ok := index(5); ok {
|
|
||||||
segments[i] = compressNetSegment(segments[i])
|
|
||||||
article = strings.Join(segments, "-")
|
|
||||||
if len([]rune(article)) <= 80 {
|
|
||||||
return article
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) compress DISK
|
|
||||||
if i, ok := index(4); ok {
|
|
||||||
segments[i] = compressDiskSegment(segments[i])
|
|
||||||
article = strings.Join(segments, "-")
|
|
||||||
if len([]rune(article)) <= 80 {
|
|
||||||
return article
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4) compress GPU to vendor only (GPU_NV)
|
|
||||||
if i, ok := index(3); ok {
|
|
||||||
segments[i] = compressGPUSegment(segments[i])
|
|
||||||
}
|
|
||||||
return strings.Join(segments, "-")
|
|
||||||
}
|
|
||||||
|
|
||||||
func compressNetSegment(seg string) string {
|
|
||||||
if seg == "" {
|
|
||||||
return seg
|
|
||||||
}
|
|
||||||
parts := strings.Split(seg, "+")
|
|
||||||
out := make([]string, 0, len(parts))
|
|
||||||
for _, p := range parts {
|
|
||||||
p = strings.TrimSpace(p)
|
|
||||||
if p == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
qty := "1"
|
|
||||||
profile := p
|
|
||||||
if x := strings.SplitN(p, "x", 2); len(x) == 2 {
|
|
||||||
qty = x[0]
|
|
||||||
profile = x[1]
|
|
||||||
}
|
|
||||||
upper := strings.ToUpper(profile)
|
|
||||||
label := "NIC"
|
|
||||||
if strings.Contains(upper, "FC") {
|
|
||||||
label = "HBA"
|
|
||||||
} else if strings.Contains(upper, "HCA") || strings.Contains(upper, "IB") {
|
|
||||||
label = "HCA"
|
|
||||||
}
|
|
||||||
out = append(out, fmt.Sprintf("%sx%s", qty, label))
|
|
||||||
}
|
|
||||||
if len(out) == 0 {
|
|
||||||
return seg
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return strings.Join(out, "+")
|
|
||||||
}
|
|
||||||
|
|
||||||
func compressDiskSegment(seg string) string {
|
|
||||||
if seg == "" {
|
|
||||||
return seg
|
|
||||||
}
|
|
||||||
parts := strings.Split(seg, "+")
|
|
||||||
out := make([]string, 0, len(parts))
|
|
||||||
for _, p := range parts {
|
|
||||||
p = strings.TrimSpace(p)
|
|
||||||
if p == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
qty := "1"
|
|
||||||
spec := p
|
|
||||||
if x := strings.SplitN(p, "x", 2); len(x) == 2 {
|
|
||||||
qty = x[0]
|
|
||||||
spec = x[1]
|
|
||||||
}
|
|
||||||
upper := strings.ToUpper(spec)
|
|
||||||
label := "DSK"
|
|
||||||
for _, t := range []string{"M2", "NV", "SAS", "SAT", "SSD", "HDD", "EDS", "HHH"} {
|
|
||||||
if strings.Contains(upper, t) {
|
|
||||||
label = t
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out = append(out, fmt.Sprintf("%sx%s", qty, label))
|
|
||||||
}
|
|
||||||
if len(out) == 0 {
|
|
||||||
return seg
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return strings.Join(out, "+")
|
|
||||||
}
|
|
||||||
|
|
||||||
func compressGPUSegment(seg string) string {
|
|
||||||
if seg == "" {
|
|
||||||
return seg
|
|
||||||
}
|
|
||||||
parts := strings.Split(seg, "+")
|
|
||||||
out := make([]string, 0, len(parts))
|
|
||||||
for _, p := range parts {
|
|
||||||
p = strings.TrimSpace(p)
|
|
||||||
if p == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
qty := "1"
|
|
||||||
if x := strings.SplitN(p, "x", 2); len(x) == 2 {
|
|
||||||
qty = x[0]
|
|
||||||
}
|
|
||||||
out = append(out, fmt.Sprintf("%sxGPU_NV", qty))
|
|
||||||
}
|
|
||||||
if len(out) == 0 {
|
|
||||||
return seg
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return strings.Join(out, "+")
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
package article
|
|
||||||
|
|
||||||
import (
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestBuild_ParsesNetAndPSU(t *testing.T) {
|
|
||||||
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("init local db: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = local.Close() })
|
|
||||||
|
|
||||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
|
||||||
ServerID: 1,
|
|
||||||
Source: "estimate",
|
|
||||||
Version: "S-2026-02-11-001",
|
|
||||||
Name: "test",
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
SyncedAt: time.Now(),
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("save local pricelist: %v", err)
|
|
||||||
}
|
|
||||||
localPL, err := local.GetLocalPricelistByServerID(1)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get local pricelist: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
|
||||||
{PricelistID: localPL.ID, LotName: "NIC_2p25G_MCX512A-AC", LotCategory: "NIC", Price: 1},
|
|
||||||
{PricelistID: localPL.ID, LotName: "HBA_2pFC32_Gen6", LotCategory: "HBA", Price: 1},
|
|
||||||
{PricelistID: localPL.ID, LotName: "PS_1000W_Platinum", LotCategory: "PS", Price: 1},
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("save local items: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
items := models.ConfigItems{
|
|
||||||
{LotName: "NIC_2p25G_MCX512A-AC", Quantity: 1},
|
|
||||||
{LotName: "HBA_2pFC32_Gen6", Quantity: 1},
|
|
||||||
{LotName: "PS_1000W_Platinum", Quantity: 2},
|
|
||||||
}
|
|
||||||
result, err := Build(local, items, BuildOptions{
|
|
||||||
ServerModel: "DL380GEN11",
|
|
||||||
SupportCode: "1yW",
|
|
||||||
ServerPricelist: &localPL.ServerID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build article: %v", err)
|
|
||||||
}
|
|
||||||
if result.Article == "" {
|
|
||||||
t.Fatalf("expected article to be non-empty")
|
|
||||||
}
|
|
||||||
if contains(result.Article, "UNKNET") || contains(result.Article, "UNKPSU") {
|
|
||||||
t.Fatalf("unexpected UNK in article: %s", result.Article)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func contains(s, sub string) bool {
|
|
||||||
return strings.Contains(s, sub)
|
|
||||||
}
|
|
||||||
@@ -2,12 +2,9 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,7 +17,6 @@ type Config struct {
|
|||||||
Alerts AlertsConfig `yaml:"alerts"`
|
Alerts AlertsConfig `yaml:"alerts"`
|
||||||
Notifications NotificationsConfig `yaml:"notifications"`
|
Notifications NotificationsConfig `yaml:"notifications"`
|
||||||
Logging LoggingConfig `yaml:"logging"`
|
Logging LoggingConfig `yaml:"logging"`
|
||||||
Backup BackupConfig `yaml:"backup"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
@@ -43,18 +39,8 @@ type DatabaseConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *DatabaseConfig) DSN() string {
|
func (d *DatabaseConfig) DSN() string {
|
||||||
cfg := mysqlDriver.NewConfig()
|
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||||
cfg.User = d.User
|
d.User, d.Password, d.Host, d.Port, d.Name)
|
||||||
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 {
|
||||||
@@ -102,10 +88,6 @@ type LoggingConfig struct {
|
|||||||
FilePath string `yaml:"file_path"`
|
FilePath string `yaml:"file_path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type BackupConfig struct {
|
|
||||||
Time string `yaml:"time"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func Load(path string) (*Config, error) {
|
func Load(path string) (*Config, error) {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -187,10 +169,6 @@ func (c *Config) setDefaults() {
|
|||||||
if c.Logging.Output == "" {
|
if c.Logging.Output == "" {
|
||||||
c.Logging.Output = "stdout"
|
c.Logging.Output = "stdout"
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.Backup.Time == "" {
|
|
||||||
c.Backup.Time = "00:00"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) Address() string {
|
func (c *Config) Address() string {
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ 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"
|
||||||
@@ -27,12 +25,6 @@ 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"),
|
||||||
@@ -41,26 +33,39 @@ func (h *ComponentHandler) List(c *gin.Context) {
|
|||||||
ExcludeHidden: c.Query("include_hidden") != "true", // По умолчанию скрытые не показываются
|
ExcludeHidden: c.Query("include_hidden") != "true", // По умолчанию скрытые не показываются
|
||||||
}
|
}
|
||||||
|
|
||||||
localFilter := localdb.ComponentFilter{
|
result, err := h.componentService.List(filter, page, perPage)
|
||||||
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
|
||||||
|
isOffline := false
|
||||||
|
if v, ok := c.Get("is_offline"); ok {
|
||||||
|
if b, ok := v.(bool); ok {
|
||||||
|
isOffline = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isOffline && result.Total == 0 && h.localDB != nil {
|
||||||
|
localFilter := localdb.ComponentFilter{
|
||||||
|
Category: filter.Category,
|
||||||
|
Search: filter.Search,
|
||||||
|
HasPrice: filter.HasPrice,
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := (page - 1) * perPage
|
||||||
|
localComps, total, err := h.localDB.ListComponents(localFilter, offset, perPage)
|
||||||
|
if err == nil && len(localComps) > 0 {
|
||||||
|
// Convert local components to ComponentView format
|
||||||
components := make([]services.ComponentView, len(localComps))
|
components := make([]services.ComponentView, len(localComps))
|
||||||
for i, lc := range localComps {
|
for i, lc := range localComps {
|
||||||
components[i] = services.ComponentView{
|
components[i] = services.ComponentView{
|
||||||
LotName: lc.LotName,
|
LotName: lc.LotName,
|
||||||
Description: lc.LotDescription,
|
Description: lc.LotDescription,
|
||||||
Category: lc.Category,
|
Category: lc.Category,
|
||||||
CategoryName: lc.Category,
|
CategoryName: lc.Category, // No translation in local mode
|
||||||
Model: lc.Model,
|
Model: lc.Model,
|
||||||
|
CurrentPrice: lc.CurrentPrice,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,39 +75,31 @@ func (h *ComponentHandler) List(c *gin.Context) {
|
|||||||
Page: page,
|
Page: page,
|
||||||
PerPage: perPage,
|
PerPage: perPage,
|
||||||
})
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
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, services.ComponentView{
|
c.JSON(http.StatusOK, component)
|
||||||
LotName: component.LotName,
|
|
||||||
Description: component.LotDescription,
|
|
||||||
Category: component.Category,
|
|
||||||
CategoryName: component.Category,
|
|
||||||
Model: component.Model,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *ComponentHandler) GetCategories(c *gin.Context) {
|
func (h *ComponentHandler) GetCategories(c *gin.Context) {
|
||||||
codes, err := h.localDB.GetLocalComponentCategories()
|
categories, err := h.componentService.GetCategories()
|
||||||
if err == nil && len(codes) > 0 {
|
if err != nil {
|
||||||
categories := make([]models.Category, 0, len(codes))
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
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, models.DefaultCategories)
|
c.JSON(http.StatusOK, categories)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,9 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/middleware"
|
"git.mchus.pro/mchus/quoteforge/internal/middleware"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -15,28 +13,23 @@ import (
|
|||||||
type ExportHandler struct {
|
type ExportHandler struct {
|
||||||
exportService *services.ExportService
|
exportService *services.ExportService
|
||||||
configService services.ConfigurationGetter
|
configService services.ConfigurationGetter
|
||||||
projectService *services.ProjectService
|
componentService *services.ComponentService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewExportHandler(
|
func NewExportHandler(
|
||||||
exportService *services.ExportService,
|
exportService *services.ExportService,
|
||||||
configService services.ConfigurationGetter,
|
configService services.ConfigurationGetter,
|
||||||
projectService *services.ProjectService,
|
componentService *services.ComponentService,
|
||||||
) *ExportHandler {
|
) *ExportHandler {
|
||||||
return &ExportHandler{
|
return &ExportHandler{
|
||||||
exportService: exportService,
|
exportService: exportService,
|
||||||
configService: configService,
|
configService: configService,
|
||||||
projectService: projectService,
|
componentService: componentService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExportRequest struct {
|
type ExportRequest struct {
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
ProjectName string `json:"project_name"`
|
|
||||||
ProjectUUID string `json:"project_uuid"`
|
|
||||||
Article string `json:"article"`
|
|
||||||
ServerCount int `json:"server_count"`
|
|
||||||
PricelistID *uint `json:"pricelist_id"`
|
|
||||||
Items []struct {
|
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"`
|
||||||
@@ -54,162 +47,75 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
|||||||
|
|
||||||
data := h.buildExportData(&req)
|
data := h.buildExportData(&req)
|
||||||
|
|
||||||
// Validate before streaming (can return JSON error)
|
csvData, err := h.exportService.ToCSV(data)
|
||||||
if len(data.Configs) == 0 || len(data.Configs[0].Items) == 0 {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get project code for filename
|
filename := fmt.Sprintf("%s %s SPEC.csv", time.Now().Format("2006-01-02"), req.Name)
|
||||||
projectCode := req.ProjectName // legacy field: may contain code from frontend
|
|
||||||
if projectCode == "" && req.ProjectUUID != "" {
|
|
||||||
username := middleware.GetUsername(c)
|
|
||||||
if project, err := h.projectService.GetByUUID(req.ProjectUUID, username); err == nil && project != nil {
|
|
||||||
projectCode = project.Code
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if projectCode == "" {
|
|
||||||
projectCode = req.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set headers before streaming
|
|
||||||
exportDate := data.CreatedAt
|
|
||||||
articleSegment := sanitizeFilenameSegment(req.Article)
|
|
||||||
if articleSegment == "" {
|
|
||||||
articleSegment = "BOM"
|
|
||||||
}
|
|
||||||
filename := fmt.Sprintf("%s (%s) %s %s.csv", exportDate.Format("2006-01-02"), projectCode, req.Name, articleSegment)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildExportData converts an ExportRequest into a ProjectExportData using a temporary Configuration model
|
func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData {
|
||||||
// so that ExportService.ConfigToExportData can resolve categories via localDB.
|
items := make([]services.ExportItem, len(req.Items))
|
||||||
func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ProjectExportData {
|
var total float64
|
||||||
configItems := make(models.ConfigItems, len(req.Items))
|
|
||||||
for i, item := range req.Items {
|
for i, item := range req.Items {
|
||||||
configItems[i] = models.ConfigItem{
|
itemTotal := item.UnitPrice * float64(item.Quantity)
|
||||||
|
|
||||||
|
// Получаем информацию о компоненте для заполнения категории и описания
|
||||||
|
componentView, err := h.componentService.GetByLotName(item.LotName)
|
||||||
|
if err != nil {
|
||||||
|
// Если не удалось получить информацию о компоненте, используем только основные данные
|
||||||
|
items[i] = services.ExportItem{
|
||||||
LotName: item.LotName,
|
LotName: item.LotName,
|
||||||
Quantity: item.Quantity,
|
Quantity: item.Quantity,
|
||||||
UnitPrice: item.UnitPrice,
|
UnitPrice: item.UnitPrice,
|
||||||
|
TotalPrice: itemTotal,
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
items[i] = services.ExportItem{
|
||||||
|
LotName: item.LotName,
|
||||||
|
Description: componentView.Description,
|
||||||
|
Category: componentView.Category,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
UnitPrice: item.UnitPrice,
|
||||||
|
TotalPrice: itemTotal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total += itemTotal
|
||||||
}
|
}
|
||||||
|
|
||||||
serverCount := req.ServerCount
|
return &services.ExportData{
|
||||||
if serverCount < 1 {
|
Name: req.Name,
|
||||||
serverCount = 1
|
Items: items,
|
||||||
}
|
Total: total,
|
||||||
|
Notes: req.Notes,
|
||||||
cfg := &models.Configuration{
|
|
||||||
Article: req.Article,
|
|
||||||
ServerCount: serverCount,
|
|
||||||
PricelistID: req.PricelistID,
|
|
||||||
Items: configItems,
|
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
return h.exportService.ConfigToExportData(cfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sanitizeFilenameSegment(value string) string {
|
|
||||||
if strings.TrimSpace(value) == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
replacer := strings.NewReplacer(
|
|
||||||
"/", "_",
|
|
||||||
"\\", "_",
|
|
||||||
":", "_",
|
|
||||||
"*", "_",
|
|
||||||
"?", "_",
|
|
||||||
"\"", "_",
|
|
||||||
"<", "_",
|
|
||||||
">", "_",
|
|
||||||
"|", "_",
|
|
||||||
)
|
|
||||||
return strings.TrimSpace(replacer.Replace(value))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
|
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()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
data := h.exportService.ConfigToExportData(config)
|
data := h.exportService.ConfigToExportData(config, h.componentService)
|
||||||
|
|
||||||
// Validate before streaming (can return JSON error)
|
csvData, err := h.exportService.ToCSV(data)
|
||||||
if len(data.Configs) == 0 || len(data.Configs[0].Items) == 0 {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get project code for filename
|
|
||||||
projectCode := config.Name // fallback: use config name if no project
|
|
||||||
if config.ProjectUUID != nil && *config.ProjectUUID != "" {
|
|
||||||
if project, err := h.projectService.GetByUUID(*config.ProjectUUID, username); err == nil && project != nil {
|
|
||||||
projectCode = project.Code
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set headers before streaming
|
|
||||||
// Use price update time if available, otherwise creation time
|
|
||||||
exportDate := config.CreatedAt
|
|
||||||
if config.PriceUpdatedAt != nil {
|
|
||||||
exportDate = *config.PriceUpdatedAt
|
|
||||||
}
|
|
||||||
filename := fmt.Sprintf("%s (%s) %s BOM.csv", exportDate.Format("2006-01-02"), projectCode, config.Name)
|
|
||||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
|
||||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
|
||||||
|
|
||||||
// Stream CSV (cannot return JSON after this point)
|
|
||||||
if err := h.exportService.ToCSV(c.Writer, data); err != nil {
|
|
||||||
c.Error(err) // Log only
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExportProjectCSV exports all active configurations of a project as a single CSV file.
|
|
||||||
func (h *ExportHandler) ExportProjectCSV(c *gin.Context) {
|
|
||||||
username := middleware.GetUsername(c)
|
|
||||||
projectUUID := c.Param("uuid")
|
|
||||||
|
|
||||||
project, err := h.projectService.GetByUUID(projectUUID, username)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := h.projectService.ListConfigurations(projectUUID, username, "active")
|
|
||||||
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 len(result.Configs) == 0 {
|
filename := fmt.Sprintf("%s %s SPEC.csv", config.CreatedAt.Format("2006-01-02"), config.Name)
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no configurations to export"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
data := h.exportService.ProjectToExportData(result.Configs)
|
|
||||||
|
|
||||||
// Filename: YYYY-MM-DD (ProjectCode) BOM.csv
|
|
||||||
filename := fmt.Sprintf("%s (%s) BOM.csv", time.Now().Format("2006-01-02"), project.Code)
|
|
||||||
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)
|
||||||
if err := h.exportService.ToCSV(c.Writer, data); err != nil {
|
|
||||||
c.Error(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,305 +0,0 @@
|
|||||||
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 handler with mocks
|
|
||||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
|
||||||
handler := NewExportHandler(
|
|
||||||
exportSvc,
|
|
||||||
&mockConfigService{},
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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.Equal(actualBOM, expectedBOM) {
|
|
||||||
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) != 8 {
|
|
||||||
t.Errorf("Expected 8 columns, got %d", len(header))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExportCSV_InvalidRequest(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
|
|
||||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
|
||||||
handler := NewExportHandler(
|
|
||||||
exportSvc,
|
|
||||||
&mockConfigService{},
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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, nil)
|
|
||||||
handler := NewExportHandler(
|
|
||||||
exportSvc,
|
|
||||||
&mockConfigService{},
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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, nil)
|
|
||||||
handler := NewExportHandler(
|
|
||||||
exportSvc,
|
|
||||||
&mockConfigService{config: mockConfig},
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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.Equal(actualBOM, expectedBOM) {
|
|
||||||
t.Errorf("UTF-8 BOM mismatch")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExportConfigCSV_NotFound(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
|
|
||||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
|
||||||
handler := NewExportHandler(
|
|
||||||
exportSvc,
|
|
||||||
&mockConfigService{err: errors.New("config not found")},
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
|
|
||||||
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, nil)
|
|
||||||
handler := NewExportHandler(
|
|
||||||
exportSvc,
|
|
||||||
&mockConfigService{config: mockConfig},
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
|
|
||||||
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,119 +2,72 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PricelistHandler struct {
|
type PricelistHandler struct {
|
||||||
|
service *pricelist.Service
|
||||||
localDB *localdb.LocalDB
|
localDB *localdb.LocalDB
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPricelistHandler(localDB *localdb.LocalDB) *PricelistHandler {
|
func NewPricelistHandler(service *pricelist.Service, localDB *localdb.LocalDB) *PricelistHandler {
|
||||||
return &PricelistHandler{localDB: localDB}
|
return &PricelistHandler{service: service, 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"
|
|
||||||
|
|
||||||
localPLs, err := h.localDB.GetLocalPricelists()
|
pricelists, total, err := h.service.List(page, perPage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if source != "" {
|
|
||||||
filtered := localPLs[:0]
|
// If offline (empty list), fallback to local pricelists
|
||||||
for _, lpl := range localPLs {
|
if total == 0 && h.localDB != nil {
|
||||||
if strings.EqualFold(lpl.Source, source) {
|
localPLs, err := h.localDB.GetLocalPricelists()
|
||||||
filtered = append(filtered, lpl)
|
if err == nil && len(localPLs) > 0 {
|
||||||
}
|
// Convert to PricelistSummary format
|
||||||
}
|
summaries := make([]map[string]interface{}, len(localPLs))
|
||||||
localPLs = filtered
|
for i, lpl := range localPLs {
|
||||||
}
|
summaries[i] = map[string]interface{}{
|
||||||
type pricelistWithCount struct {
|
|
||||||
pricelist localdb.LocalPricelist
|
|
||||||
itemCount int64
|
|
||||||
usageCount int
|
|
||||||
}
|
|
||||||
withCounts := make([]pricelistWithCount, 0, len(localPLs))
|
|
||||||
for _, lpl := range localPLs {
|
|
||||||
itemCount := h.localDB.CountLocalPricelistItems(lpl.ID)
|
|
||||||
if activeOnly && itemCount == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
usageCount := 0
|
|
||||||
if lpl.IsUsed {
|
|
||||||
usageCount = 1
|
|
||||||
}
|
|
||||||
withCounts = append(withCounts, pricelistWithCount{
|
|
||||||
pricelist: lpl,
|
|
||||||
itemCount: itemCount,
|
|
||||||
usageCount: usageCount,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
localPLs = localPLs[:0]
|
|
||||||
for _, row := range withCounts {
|
|
||||||
localPLs = append(localPLs, row.pricelist)
|
|
||||||
}
|
|
||||||
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 := int64(0)
|
|
||||||
usageCount := 0
|
|
||||||
for _, row := range withCounts {
|
|
||||||
if row.pricelist.ID == lpl.ID {
|
|
||||||
itemCount = row.itemCount
|
|
||||||
usageCount = row.usageCount
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
summaries = append(summaries, map[string]interface{}{
|
|
||||||
"id": lpl.ServerID,
|
"id": lpl.ServerID,
|
||||||
"source": lpl.Source,
|
|
||||||
"version": lpl.Version,
|
"version": lpl.Version,
|
||||||
"created_by": "sync",
|
"created_by": "sync",
|
||||||
"item_count": itemCount,
|
"item_count": 0, // Not tracked
|
||||||
"usage_count": usageCount,
|
"usage_count": 0, // Not tracked in local
|
||||||
"is_active": true,
|
"is_active": true,
|
||||||
"created_at": lpl.CreatedAt,
|
"created_at": lpl.CreatedAt,
|
||||||
"synced_from": "local",
|
"synced_from": "local",
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"pricelists": summaries,
|
"pricelists": summaries,
|
||||||
|
"total": len(summaries),
|
||||||
|
"page": page,
|
||||||
|
"per_page": perPage,
|
||||||
|
"offline": true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"pricelists": pricelists,
|
||||||
"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)
|
||||||
@@ -123,25 +76,68 @@ func (h *PricelistHandler) Get(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
localPL, err := h.localDB.GetLocalPricelistByServerID(uint(id))
|
pl, err := h.service.GetByID(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, gin.H{
|
c.JSON(http.StatusOK, pl)
|
||||||
"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",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetItems returns items for a pricelist with pagination.
|
// Create creates a new pricelist from current prices
|
||||||
|
func (h *PricelistHandler) Create(c *gin.Context) {
|
||||||
|
canWrite, debugInfo := h.service.CanWriteDebug()
|
||||||
|
if !canWrite {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{
|
||||||
|
"error": "pricelist write is not allowed",
|
||||||
|
"debug": debugInfo,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
canWrite, debugInfo := h.service.CanWriteDebug()
|
||||||
|
if !canWrite {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{
|
||||||
|
"error": "pricelist write is not allowed",
|
||||||
|
"debug": debugInfo,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idStr := c.Param("id")
|
||||||
|
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -154,102 +150,57 @@ 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")
|
||||||
|
|
||||||
localPL, err := h.localDB.GetLocalPricelistByServerID(uint(id))
|
items, total, err := h.service.GetItems(uint(id), page, perPage, search)
|
||||||
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 {
|
|
||||||
resultItems = append(resultItems, gin.H{
|
|
||||||
"id": item.ID,
|
|
||||||
"lot_name": item.LotName,
|
|
||||||
"price": item.Price,
|
|
||||||
"category": item.LotCategory,
|
|
||||||
"available_qty": item.AvailableQty,
|
|
||||||
"partnumbers": []string(item.Partnumbers),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"source": localPL.Source,
|
"items": items,
|
||||||
"items": resultItems,
|
|
||||||
"total": total,
|
"total": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"per_page": perPage,
|
"per_page": perPage,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *PricelistHandler) GetLotNames(c *gin.Context) {
|
// CanWrite returns whether the current user can create pricelists
|
||||||
idStr := c.Param("id")
|
func (h *PricelistHandler) CanWrite(c *gin.Context) {
|
||||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
canWrite, debugInfo := h.service.CanWriteDebug()
|
||||||
if err != nil {
|
c.JSON(http.StatusOK, gin.H{"can_write": canWrite, "debug": debugInfo})
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
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.
|
// GetLatest returns the most recent active pricelist
|
||||||
func (h *PricelistHandler) GetLatest(c *gin.Context) {
|
func (h *PricelistHandler) GetLatest(c *gin.Context) {
|
||||||
source := c.DefaultQuery("source", string(models.PricelistSourceEstimate))
|
// Try to get from server first
|
||||||
source = string(models.NormalizePricelistSource(source))
|
pl, err := h.service.GetLatestActive()
|
||||||
|
|
||||||
localPL, err := h.localDB.GetLatestLocalPricelistBySource(source)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "no pricelists available"})
|
// If offline or no server pricelists, try to get from local cache
|
||||||
|
if h.localDB == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "no database available"})
|
||||||
return
|
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{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"id": localPL.ServerID,
|
"id": localPL.ServerID,
|
||||||
"source": localPL.Source,
|
|
||||||
"version": localPL.Version,
|
"version": localPL.Version,
|
||||||
"created_by": "sync",
|
"created_by": "sync",
|
||||||
"item_count": h.localDB.CountLocalPricelistItems(localPL.ID),
|
"item_count": 0, // Not tracked in local pricelists
|
||||||
"is_active": true,
|
"is_active": true,
|
||||||
"created_at": localPL.CreatedAt,
|
"created_at": localPL.CreatedAt,
|
||||||
"synced_from": "local",
|
"synced_from": "local",
|
||||||
})
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, pl)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestPricelistGetItems_ReturnsLotCategoryFromLocalPricelistItems(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
|
|
||||||
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("init local db: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = local.Close() })
|
|
||||||
|
|
||||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
|
||||||
ServerID: 1,
|
|
||||||
Source: "estimate",
|
|
||||||
Version: "S-2026-02-11-001",
|
|
||||||
Name: "test",
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
SyncedAt: time.Now(),
|
|
||||||
IsUsed: false,
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("save local pricelist: %v", err)
|
|
||||||
}
|
|
||||||
localPL, err := local.GetLocalPricelistByServerID(1)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get local pricelist: %v", err)
|
|
||||||
}
|
|
||||||
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
|
||||||
{
|
|
||||||
PricelistID: localPL.ID,
|
|
||||||
LotName: "NO_UNDERSCORE_NAME",
|
|
||||||
LotCategory: "CPU",
|
|
||||||
Price: 10,
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("save local pricelist items: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
h := NewPricelistHandler(local)
|
|
||||||
|
|
||||||
req, _ := http.NewRequest("GET", "/api/pricelists/1/items?page=1&per_page=50", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
c, _ := gin.CreateTestContext(w)
|
|
||||||
c.Request = req
|
|
||||||
c.Params = gin.Params{{Key: "id", Value: "1"}}
|
|
||||||
|
|
||||||
h.GetItems(c)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp struct {
|
|
||||||
Items []struct {
|
|
||||||
LotName string `json:"lot_name"`
|
|
||||||
Category string `json:"category"`
|
|
||||||
UnitPrice any `json:"price"`
|
|
||||||
} `json:"items"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("unmarshal response: %v", err)
|
|
||||||
}
|
|
||||||
if len(resp.Items) != 1 {
|
|
||||||
t.Fatalf("expected 1 item, got %d", len(resp.Items))
|
|
||||||
}
|
|
||||||
if resp.Items[0].LotName != "NO_UNDERSCORE_NAME" {
|
|
||||||
t.Fatalf("expected lot_name NO_UNDERSCORE_NAME, got %q", resp.Items[0].LotName)
|
|
||||||
}
|
|
||||||
if resp.Items[0].Category != "CPU" {
|
|
||||||
t.Fatalf("expected category CPU, got %q", resp.Items[0].Category)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPricelistList_ActiveOnlyExcludesPricelistsWithoutItems(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
|
|
||||||
local, err := localdb.New(filepath.Join(t.TempDir(), "local_active_only.db"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("init local db: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = local.Close() })
|
|
||||||
|
|
||||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
|
||||||
ServerID: 10,
|
|
||||||
Source: "estimate",
|
|
||||||
Version: "E-1",
|
|
||||||
Name: "with-items",
|
|
||||||
CreatedAt: time.Now().Add(-time.Minute),
|
|
||||||
SyncedAt: time.Now().Add(-time.Minute),
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("save with-items pricelist: %v", err)
|
|
||||||
}
|
|
||||||
withItems, err := local.GetLocalPricelistByServerID(10)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("load with-items pricelist: %v", err)
|
|
||||||
}
|
|
||||||
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
|
||||||
{
|
|
||||||
PricelistID: withItems.ID,
|
|
||||||
LotName: "CPU_X",
|
|
||||||
LotCategory: "CPU",
|
|
||||||
Price: 100,
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("save with-items pricelist items: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
|
||||||
ServerID: 11,
|
|
||||||
Source: "estimate",
|
|
||||||
Version: "E-2",
|
|
||||||
Name: "without-items",
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
SyncedAt: time.Now(),
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("save without-items pricelist: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
h := NewPricelistHandler(local)
|
|
||||||
|
|
||||||
req, _ := http.NewRequest("GET", "/api/pricelists?source=estimate&active_only=true", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
c, _ := gin.CreateTestContext(w)
|
|
||||||
c.Request = req
|
|
||||||
|
|
||||||
h.List(c)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp struct {
|
|
||||||
Pricelists []struct {
|
|
||||||
ID uint `json:"id"`
|
|
||||||
} `json:"pricelists"`
|
|
||||||
Total int `json:"total"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("unmarshal response: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Total != 1 {
|
|
||||||
t.Fatalf("expected total=1, got %d", resp.Total)
|
|
||||||
}
|
|
||||||
if len(resp.Pricelists) != 1 {
|
|
||||||
t.Fatalf("expected 1 pricelist, got %d", len(resp.Pricelists))
|
|
||||||
}
|
|
||||||
if resp.Pricelists[0].ID != 10 {
|
|
||||||
t.Fatalf("expected pricelist id=10, got %d", resp.Pricelists[0].ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
938
internal/handlers/pricing.go
Normal file
938
internal/handlers/pricing.go
Normal file
@@ -0,0 +1,938 @@
|
|||||||
|
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"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
type QuoteHandler struct {
|
type QuoteHandler struct {
|
||||||
@@ -49,19 +49,3 @@ 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,17 +4,17 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
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"
|
||||||
gormmysql "gorm.io/driver/mysql"
|
"gorm.io/driver/mysql"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
)
|
)
|
||||||
@@ -26,7 +26,7 @@ type SetupHandler struct {
|
|||||||
restartSig chan struct{}
|
restartSig chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, _ string, restartSig chan struct{}) (*SetupHandler, error) {
|
func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, templatesPath string, restartSig chan struct{}) (*SetupHandler, error) {
|
||||||
funcMap := template.FuncMap{
|
funcMap := template.FuncMap{
|
||||||
"sub": func(a, b int) int { return a - b },
|
"sub": func(a, b int) int { return a - b },
|
||||||
"add": func(a, b int) int { return a + b },
|
"add": func(a, b int) int { return a + b },
|
||||||
@@ -35,9 +35,14 @@ func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, _
|
|||||||
templates := make(map[string]*template.Template)
|
templates := make(map[string]*template.Template)
|
||||||
|
|
||||||
// Load setup template (standalone, no base needed)
|
// Load setup template (standalone, no base needed)
|
||||||
|
setupPath := filepath.Join(templatesPath, "setup.html")
|
||||||
var tmpl *template.Template
|
var tmpl *template.Template
|
||||||
var err error
|
var err error
|
||||||
|
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
|
||||||
|
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(setupPath)
|
||||||
|
} else {
|
||||||
tmpl, err = template.New("").Funcs(funcMap).ParseFS(qfassets.TemplatesFS, "web/templates/setup.html")
|
tmpl, err = template.New("").Funcs(funcMap).ParseFS(qfassets.TemplatesFS, "web/templates/setup.html")
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parsing setup template: %w", err)
|
return nil, fmt.Errorf("parsing setup template: %w", err)
|
||||||
}
|
}
|
||||||
@@ -88,9 +93,10 @@ func (h *SetupHandler) TestConnection(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second)
|
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s",
|
||||||
|
user, password, host, port, database)
|
||||||
|
|
||||||
db, err := gorm.Open(gormmysql.Open(dsn), &gorm.Config{
|
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||||
Logger: logger.Default.LogMode(logger.Silent),
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -163,9 +169,10 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Test connection first
|
// Test connection first
|
||||||
dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second)
|
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s",
|
||||||
|
user, password, host, port, database)
|
||||||
|
|
||||||
db, err := gorm.Open(gormmysql.Open(dsn), &gorm.Config{
|
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||||
Logger: logger.Default.LogMode(logger.Silent),
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -247,19 +254,3 @@ 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,12 +1,11 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
stdsync "sync"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
qfassets "git.mchus.pro/mchus/quoteforge"
|
qfassets "git.mchus.pro/mchus/quoteforge"
|
||||||
@@ -21,18 +20,20 @@ type SyncHandler struct {
|
|||||||
localDB *localdb.LocalDB
|
localDB *localdb.LocalDB
|
||||||
syncService *sync.Service
|
syncService *sync.Service
|
||||||
connMgr *db.ConnectionManager
|
connMgr *db.ConnectionManager
|
||||||
autoSyncInterval time.Duration
|
|
||||||
onlineGraceFactor float64
|
|
||||||
tmpl *template.Template
|
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, _ string, autoSyncInterval time.Duration) (*SyncHandler, error) {
|
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, templatesPath string) (*SyncHandler, error) {
|
||||||
// Load sync_status partial template
|
// Load sync_status partial template
|
||||||
tmpl, err := template.ParseFS(qfassets.TemplatesFS, "web/templates/partials/sync_status.html")
|
partialPath := filepath.Join(templatesPath, "partials", "sync_status.html")
|
||||||
|
var tmpl *template.Template
|
||||||
|
var err error
|
||||||
|
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
|
||||||
|
tmpl, err = template.ParseFiles(partialPath)
|
||||||
|
} else {
|
||||||
|
tmpl, err = template.ParseFS(qfassets.TemplatesFS, "web/templates/partials/sync_status.html")
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -41,8 +42,6 @@ func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr
|
|||||||
localDB: localDB,
|
localDB: localDB,
|
||||||
syncService: syncService,
|
syncService: syncService,
|
||||||
connMgr: connMgr,
|
connMgr: connMgr,
|
||||||
autoSyncInterval: autoSyncInterval,
|
|
||||||
onlineGraceFactor: 1.10,
|
|
||||||
tmpl: tmpl,
|
tmpl: tmpl,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -57,16 +56,6 @@ type SyncStatusResponse struct {
|
|||||||
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
|
||||||
@@ -96,7 +85,6 @@ 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,
|
||||||
@@ -107,63 +95,9 @@ 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"`
|
||||||
@@ -175,7 +109,11 @@ 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.ensureSyncReadiness(c) {
|
if !h.checkOnline() {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": "Database is offline",
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,7 +148,11 @@ 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.ensureSyncReadiness(c) {
|
if !h.checkOnline() {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": "Database is offline",
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,47 +173,30 @@ 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"`
|
||||||
PendingPushed int `json:"pending_pushed"`
|
|
||||||
ComponentsSynced int `json:"components_synced"`
|
ComponentsSynced int `json:"components_synced"`
|
||||||
PricelistsSynced int `json:"pricelists_synced"`
|
PricelistsSynced int `json:"pricelists_synced"`
|
||||||
ProjectsImported int `json:"projects_imported"`
|
|
||||||
ProjectsUpdated int `json:"projects_updated"`
|
|
||||||
ProjectsSkipped int `json:"projects_skipped"`
|
|
||||||
ConfigurationsImported int `json:"configurations_imported"`
|
|
||||||
ConfigurationsUpdated int `json:"configurations_updated"`
|
|
||||||
ConfigurationsSkipped int `json:"configurations_skipped"`
|
|
||||||
Duration string `json:"duration"`
|
Duration string `json:"duration"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncAll performs full bidirectional sync:
|
// SyncAll syncs both components and pricelists
|
||||||
// - 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.ensureSyncReadiness(c) {
|
if !h.checkOnline() {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": "Database is offline",
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
var pendingPushed, componentsSynced, pricelistsSynced int
|
var 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()
|
||||||
@@ -301,56 +226,18 @@ 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",
|
||||||
PendingPushed: pendingPushed,
|
|
||||||
ComponentsSynced: componentsSynced,
|
ComponentsSynced: componentsSynced,
|
||||||
PricelistsSynced: pricelistsSynced,
|
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(),
|
Duration: time.Since(startTime).String(),
|
||||||
})
|
})
|
||||||
h.syncService.RecordSyncHeartbeat()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkOnline checks if MariaDB is accessible
|
// checkOnline checks if MariaDB is accessible
|
||||||
@@ -361,7 +248,11 @@ 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.ensureSyncReadiness(c) {
|
if !h.checkOnline() {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": "Database is offline",
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,7 +273,6 @@ 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
|
||||||
@@ -410,60 +300,12 @@ func (h *SyncHandler) GetPendingChanges(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// RepairPendingChanges attempts to repair errored pending changes
|
// SyncInfoResponse represents sync information
|
||||||
// POST /api/sync/repair
|
|
||||||
func (h *SyncHandler) RepairPendingChanges(c *gin.Context) {
|
|
||||||
repaired, remainingErrors, err := h.localDB.RepairPendingChanges()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("repair pending changes failed", "error", err)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"success": false,
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"success": true,
|
|
||||||
"repaired": repaired,
|
|
||||||
"remaining_errors": remainingErrors,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// SyncInfoResponse represents sync information for the modal
|
|
||||||
type SyncInfoResponse struct {
|
type SyncInfoResponse struct {
|
||||||
// Connection
|
|
||||||
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"`
|
LastSyncAt *time.Time `json:"last_sync_at"`
|
||||||
|
IsOnline bool `json:"is_online"`
|
||||||
// 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
|
||||||
@@ -478,44 +320,31 @@ 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 pending changes
|
// Get recent errors (last 10)
|
||||||
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)
|
||||||
changes = []localdb.PendingChange{}
|
// Even if we can't get changes, we can still return the error count
|
||||||
|
c.JSON(http.StatusOK, SyncInfoResponse{
|
||||||
|
LastSyncAt: lastPricelistSync,
|
||||||
|
IsOnline: isOnline,
|
||||||
|
ErrorCount: errorCount,
|
||||||
|
Errors: []SyncError{}, // Return empty errors list
|
||||||
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var syncErrors []SyncError
|
var errors []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 != "" {
|
||||||
syncErrors = append(syncErrors, SyncError{
|
errors = append(errors, SyncError{
|
||||||
Timestamp: change.CreatedAt,
|
Timestamp: change.CreatedAt,
|
||||||
Message: change.LastError,
|
Message: change.LastError,
|
||||||
})
|
})
|
||||||
@@ -523,63 +352,15 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Limit to last 10 errors
|
// Limit to last 10 errors
|
||||||
if len(syncErrors) > 10 {
|
if len(errors) > 10 {
|
||||||
syncErrors = syncErrors[:10]
|
errors = errors[:10]
|
||||||
}
|
}
|
||||||
|
|
||||||
readiness := h.getReadinessCached(10 * time.Second)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, SyncInfoResponse{
|
c.JSON(http.StatusOK, SyncInfoResponse{
|
||||||
DBHost: dbHost,
|
|
||||||
DBUser: dbUser,
|
|
||||||
DBName: dbName,
|
|
||||||
IsOnline: isOnline,
|
|
||||||
LastSyncAt: lastPricelistSync,
|
LastSyncAt: lastPricelistSync,
|
||||||
LotCount: lotCount,
|
IsOnline: isOnline,
|
||||||
LotLogCount: lotLogCount,
|
|
||||||
ConfigCount: configCount,
|
|
||||||
ProjectCount: projectCount,
|
|
||||||
PendingChanges: changes,
|
|
||||||
ErrorCount: errorCount,
|
ErrorCount: errorCount,
|
||||||
Errors: syncErrors,
|
Errors: errors,
|
||||||
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,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -599,21 +380,12 @@ 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, "sync_blocked", isBlocked)
|
slog.Debug("rendering sync status", "is_offline", isOffline, "pending_count", pendingCount)
|
||||||
|
|
||||||
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")
|
||||||
@@ -622,24 +394,3 @@ 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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,8 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
qfassets "git.mchus.pro/mchus/quoteforge"
|
qfassets "git.mchus.pro/mchus/quoteforge"
|
||||||
@@ -15,7 +17,7 @@ type WebHandler struct {
|
|||||||
componentService *services.ComponentService
|
componentService *services.ComponentService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWebHandler(_ string, componentService *services.ComponentService) (*WebHandler, error) {
|
func NewWebHandler(templatesPath string, componentService *services.ComponentService) (*WebHandler, error) {
|
||||||
funcMap := template.FuncMap{
|
funcMap := template.FuncMap{
|
||||||
"sub": func(a, b int) int { return a - b },
|
"sub": func(a, b int) int { return a - b },
|
||||||
"add": func(a, b int) int { return a + b },
|
"add": func(a, b int) int { return a + b },
|
||||||
@@ -58,16 +60,27 @@ func NewWebHandler(_ string, componentService *services.ComponentService) (*WebH
|
|||||||
}
|
}
|
||||||
|
|
||||||
templates := make(map[string]*template.Template)
|
templates := make(map[string]*template.Template)
|
||||||
|
basePath := filepath.Join(templatesPath, "base.html")
|
||||||
|
useDisk := false
|
||||||
|
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
|
||||||
|
useDisk = true
|
||||||
|
}
|
||||||
|
|
||||||
// Load each page template with base
|
// Load each page template with base
|
||||||
simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "pricelists.html", "pricelist_detail.html", "config_revisions.html"}
|
simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "admin_pricing.html", "pricelists.html", "pricelist_detail.html"}
|
||||||
for _, page := range simplePages {
|
for _, page := range simplePages {
|
||||||
|
pagePath := filepath.Join(templatesPath, page)
|
||||||
var tmpl *template.Template
|
var tmpl *template.Template
|
||||||
var err error
|
var err error
|
||||||
|
if useDisk {
|
||||||
|
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(basePath, pagePath)
|
||||||
|
} else {
|
||||||
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
||||||
qfassets.TemplatesFS,
|
qfassets.TemplatesFS,
|
||||||
"web/templates/base.html",
|
"web/templates/base.html",
|
||||||
"web/templates/"+page,
|
"web/templates/"+page,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -75,14 +88,20 @@ func NewWebHandler(_ string, componentService *services.ComponentService) (*WebH
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Index page needs components_list.html as well
|
// Index page needs components_list.html as well
|
||||||
|
indexPath := filepath.Join(templatesPath, "index.html")
|
||||||
|
componentsListPath := filepath.Join(templatesPath, "components_list.html")
|
||||||
var indexTmpl *template.Template
|
var indexTmpl *template.Template
|
||||||
var err error
|
var err error
|
||||||
|
if useDisk {
|
||||||
|
indexTmpl, err = template.New("").Funcs(funcMap).ParseFiles(basePath, indexPath, componentsListPath)
|
||||||
|
} else {
|
||||||
indexTmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
indexTmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
||||||
qfassets.TemplatesFS,
|
qfassets.TemplatesFS,
|
||||||
"web/templates/base.html",
|
"web/templates/base.html",
|
||||||
"web/templates/index.html",
|
"web/templates/index.html",
|
||||||
"web/templates/components_list.html",
|
"web/templates/components_list.html",
|
||||||
)
|
)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -91,12 +110,17 @@ func NewWebHandler(_ string, componentService *services.ComponentService) (*WebH
|
|||||||
// Load partial templates (no base needed)
|
// Load partial templates (no base needed)
|
||||||
partials := []string{"components_list.html"}
|
partials := []string{"components_list.html"}
|
||||||
for _, partial := range partials {
|
for _, partial := range partials {
|
||||||
|
partialPath := filepath.Join(templatesPath, partial)
|
||||||
var tmpl *template.Template
|
var tmpl *template.Template
|
||||||
var err error
|
var err error
|
||||||
|
if useDisk {
|
||||||
|
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(partialPath)
|
||||||
|
} else {
|
||||||
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
||||||
qfassets.TemplatesFS,
|
qfassets.TemplatesFS,
|
||||||
"web/templates/"+partial,
|
"web/templates/"+partial,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -123,8 +147,8 @@ func (h *WebHandler) render(c *gin.Context, name string, data gin.H) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *WebHandler) Index(c *gin.Context) {
|
func (h *WebHandler) Index(c *gin.Context) {
|
||||||
// Redirect to projects page - configurator is accessed via /configurator?uuid=...
|
// Redirect to configs page - configurator is accessed via /configurator?uuid=...
|
||||||
c.Redirect(302, "/projects")
|
c.Redirect(302, "/configs")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *WebHandler) Configurator(c *gin.Context) {
|
func (h *WebHandler) Configurator(c *gin.Context) {
|
||||||
@@ -173,11 +197,8 @@ func (h *WebHandler) ProjectDetail(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *WebHandler) ConfigRevisions(c *gin.Context) {
|
func (h *WebHandler) AdminPricing(c *gin.Context) {
|
||||||
h.render(c, "config_revisions.html", gin.H{
|
h.render(c, "admin_pricing.html", gin.H{"ActivePage": "admin"})
|
||||||
"ActivePage": "configs",
|
|
||||||
"ConfigUUID": c.Param("uuid"),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *WebHandler) Pricelists(c *gin.Context) {
|
func (h *WebHandler) Pricelists(c *gin.Context) {
|
||||||
|
|||||||
@@ -28,13 +28,14 @@ type ComponentSyncResult struct {
|
|||||||
func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error) {
|
func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error) {
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
|
|
||||||
// Query to join lot with qt_lot_metadata (metadata only, no pricing)
|
// Query to join lot with qt_lot_metadata
|
||||||
// Use LEFT JOIN to include lots without metadata
|
// Use LEFT JOIN to include lots without metadata
|
||||||
type componentRow struct {
|
type componentRow struct {
|
||||||
LotName string
|
LotName string
|
||||||
LotDescription string
|
LotDescription string
|
||||||
Category *string
|
Category *string
|
||||||
Model *string
|
Model *string
|
||||||
|
CurrentPrice *float64
|
||||||
}
|
}
|
||||||
|
|
||||||
var rows []componentRow
|
var rows []componentRow
|
||||||
@@ -43,7 +44,8 @@ func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error)
|
|||||||
l.lot_name,
|
l.lot_name,
|
||||||
l.lot_description,
|
l.lot_description,
|
||||||
COALESCE(c.code, SUBSTRING_INDEX(l.lot_name, '_', 1)) as category,
|
COALESCE(c.code, SUBSTRING_INDEX(l.lot_name, '_', 1)) as category,
|
||||||
m.model
|
m.model,
|
||||||
|
m.current_price
|
||||||
FROM lot l
|
FROM lot l
|
||||||
LEFT JOIN qt_lot_metadata m ON l.lot_name = m.lot_name
|
LEFT JOIN qt_lot_metadata m ON l.lot_name = m.lot_name
|
||||||
LEFT JOIN qt_categories c ON m.category_id = c.id
|
LEFT JOIN qt_categories c ON m.category_id = c.id
|
||||||
@@ -98,6 +100,8 @@ func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error)
|
|||||||
LotDescription: row.LotDescription,
|
LotDescription: row.LotDescription,
|
||||||
Category: category,
|
Category: category,
|
||||||
Model: model,
|
Model: model,
|
||||||
|
CurrentPrice: row.CurrentPrice,
|
||||||
|
SyncedAt: syncTime,
|
||||||
}
|
}
|
||||||
components = append(components, comp)
|
components = append(components, comp)
|
||||||
|
|
||||||
@@ -217,6 +221,11 @@ func (l *LocalDB) ListComponents(filter ComponentFilter, offset, limit int) ([]L
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply price filter
|
||||||
|
if filter.HasPrice {
|
||||||
|
db = db.Where("current_price IS NOT NULL")
|
||||||
|
}
|
||||||
|
|
||||||
// Get total count
|
// Get total count
|
||||||
var total int64
|
var total int64
|
||||||
if err := db.Model(&LocalComponent{}).Count(&total).Error; err != nil {
|
if err := db.Model(&LocalComponent{}).Count(&total).Error; err != nil {
|
||||||
@@ -242,31 +251,6 @@ func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) {
|
|||||||
return &component, nil
|
return &component, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLocalComponentCategoriesByLotNames returns category for each lot_name in the local component cache.
|
|
||||||
// Missing lots are not included in the map; caller is responsible for strict validation.
|
|
||||||
func (l *LocalDB) GetLocalComponentCategoriesByLotNames(lotNames []string) (map[string]string, error) {
|
|
||||||
result := make(map[string]string, len(lotNames))
|
|
||||||
if len(lotNames) == 0 {
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type row struct {
|
|
||||||
LotName string `gorm:"column:lot_name"`
|
|
||||||
Category string `gorm:"column:category"`
|
|
||||||
}
|
|
||||||
var rows []row
|
|
||||||
if err := l.db.Model(&LocalComponent{}).
|
|
||||||
Select("lot_name, category").
|
|
||||||
Where("lot_name IN ?", lotNames).
|
|
||||||
Find(&rows).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for _, r := range rows {
|
|
||||||
result[r.LotName] = r.Category
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetLocalComponentCategories returns distinct categories from local components
|
// GetLocalComponentCategories returns distinct categories from local components
|
||||||
func (l *LocalDB) GetLocalComponentCategories() ([]string, error) {
|
func (l *LocalDB) GetLocalComponentCategories() ([]string, error) {
|
||||||
var categories []string
|
var categories []string
|
||||||
@@ -327,3 +311,100 @@ func (l *LocalDB) NeedComponentSync(maxAgeHours int) bool {
|
|||||||
}
|
}
|
||||||
return time.Since(*syncTime).Hours() > float64(maxAgeHours)
|
return time.Since(*syncTime).Hours() > float64(maxAgeHours)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateComponentPricesFromPricelist updates current_price in local_components from pricelist items
|
||||||
|
// This allows offline price updates using synced pricelists without MariaDB connection
|
||||||
|
func (l *LocalDB) UpdateComponentPricesFromPricelist(pricelistID uint) (int, error) {
|
||||||
|
// Get all items from the specified pricelist
|
||||||
|
var items []LocalPricelistItem
|
||||||
|
if err := l.db.Where("pricelist_id = ?", pricelistID).Find(&items).Error; err != nil {
|
||||||
|
return 0, fmt.Errorf("fetching pricelist items: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(items) == 0 {
|
||||||
|
slog.Warn("no items found in pricelist", "pricelist_id", pricelistID)
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update current_price for each component
|
||||||
|
updated := 0
|
||||||
|
err := l.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
for _, item := range items {
|
||||||
|
result := tx.Model(&LocalComponent{}).
|
||||||
|
Where("lot_name = ?", item.LotName).
|
||||||
|
Update("current_price", item.Price)
|
||||||
|
|
||||||
|
if result.Error != nil {
|
||||||
|
return fmt.Errorf("updating price for %s: %w", item.LotName, result.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.RowsAffected > 0 {
|
||||||
|
updated++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("updated component prices from pricelist",
|
||||||
|
"pricelist_id", pricelistID,
|
||||||
|
"total_items", len(items),
|
||||||
|
"updated_components", updated)
|
||||||
|
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureComponentPricesFromPricelists loads prices from the latest pricelist into local_components
|
||||||
|
// if no components exist or all current prices are NULL
|
||||||
|
func (l *LocalDB) EnsureComponentPricesFromPricelists() error {
|
||||||
|
// Check if we have any components with prices
|
||||||
|
var count int64
|
||||||
|
if err := l.db.Model(&LocalComponent{}).Where("current_price IS NOT NULL").Count(&count).Error; err != nil {
|
||||||
|
return fmt.Errorf("checking component prices: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have components with prices, don't load from pricelists
|
||||||
|
if count > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have any components at all
|
||||||
|
var totalComponents int64
|
||||||
|
if err := l.db.Model(&LocalComponent{}).Count(&totalComponents).Error; err != nil {
|
||||||
|
return fmt.Errorf("counting components: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have no components, we need to load them from pricelists
|
||||||
|
if totalComponents == 0 {
|
||||||
|
slog.Info("no components found in local database, loading from latest pricelist")
|
||||||
|
// This would typically be called from the sync service or setup process
|
||||||
|
// For now, we'll just return nil to indicate no action needed
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have components but no prices, we should load prices from pricelists
|
||||||
|
// Find the latest pricelist
|
||||||
|
var latestPricelist LocalPricelist
|
||||||
|
if err := l.db.Order("created_at DESC").First(&latestPricelist).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
slog.Warn("no pricelists found in local database")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("finding latest pricelist: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update prices from the latest pricelist
|
||||||
|
updated, err := l.UpdateComponentPricesFromPricelist(latestPricelist.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("updating component prices from pricelist: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("loaded component prices from latest pricelist",
|
||||||
|
"pricelist_id", latestPricelist.ID,
|
||||||
|
"updated_components", updated)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,11 +28,6 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
|
|||||||
Notes: cfg.Notes,
|
Notes: cfg.Notes,
|
||||||
IsTemplate: cfg.IsTemplate,
|
IsTemplate: cfg.IsTemplate,
|
||||||
ServerCount: cfg.ServerCount,
|
ServerCount: cfg.ServerCount,
|
||||||
ServerModel: cfg.ServerModel,
|
|
||||||
SupportCode: cfg.SupportCode,
|
|
||||||
Article: cfg.Article,
|
|
||||||
PricelistID: cfg.PricelistID,
|
|
||||||
OnlyInStock: cfg.OnlyInStock,
|
|
||||||
PriceUpdatedAt: cfg.PriceUpdatedAt,
|
PriceUpdatedAt: cfg.PriceUpdatedAt,
|
||||||
CreatedAt: cfg.CreatedAt,
|
CreatedAt: cfg.CreatedAt,
|
||||||
UpdatedAt: time.Now(),
|
UpdatedAt: time.Now(),
|
||||||
@@ -75,11 +70,6 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
|
|||||||
Notes: local.Notes,
|
Notes: local.Notes,
|
||||||
IsTemplate: local.IsTemplate,
|
IsTemplate: local.IsTemplate,
|
||||||
ServerCount: local.ServerCount,
|
ServerCount: local.ServerCount,
|
||||||
ServerModel: local.ServerModel,
|
|
||||||
SupportCode: local.SupportCode,
|
|
||||||
Article: local.Article,
|
|
||||||
PricelistID: local.PricelistID,
|
|
||||||
OnlyInStock: local.OnlyInStock,
|
|
||||||
PriceUpdatedAt: local.PriceUpdatedAt,
|
PriceUpdatedAt: local.PriceUpdatedAt,
|
||||||
CreatedAt: local.CreatedAt,
|
CreatedAt: local.CreatedAt,
|
||||||
}
|
}
|
||||||
@@ -91,9 +81,6 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
|
|||||||
userID := local.OriginalUserID
|
userID := local.OriginalUserID
|
||||||
cfg.UserID = &userID
|
cfg.UserID = &userID
|
||||||
}
|
}
|
||||||
if local.CurrentVersion != nil {
|
|
||||||
cfg.CurrentVersionNo = local.CurrentVersion.VersionNo
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
@@ -109,10 +96,7 @@ func ProjectToLocal(project *models.Project) *LocalProject {
|
|||||||
local := &LocalProject{
|
local := &LocalProject{
|
||||||
UUID: project.UUID,
|
UUID: project.UUID,
|
||||||
OwnerUsername: project.OwnerUsername,
|
OwnerUsername: project.OwnerUsername,
|
||||||
Code: project.Code,
|
|
||||||
Variant: project.Variant,
|
|
||||||
Name: project.Name,
|
Name: project.Name,
|
||||||
TrackerURL: project.TrackerURL,
|
|
||||||
IsActive: project.IsActive,
|
IsActive: project.IsActive,
|
||||||
IsSystem: project.IsSystem,
|
IsSystem: project.IsSystem,
|
||||||
CreatedAt: project.CreatedAt,
|
CreatedAt: project.CreatedAt,
|
||||||
@@ -130,10 +114,7 @@ func LocalToProject(local *LocalProject) *models.Project {
|
|||||||
project := &models.Project{
|
project := &models.Project{
|
||||||
UUID: local.UUID,
|
UUID: local.UUID,
|
||||||
OwnerUsername: local.OwnerUsername,
|
OwnerUsername: local.OwnerUsername,
|
||||||
Code: local.Code,
|
|
||||||
Variant: local.Variant,
|
|
||||||
Name: local.Name,
|
Name: local.Name,
|
||||||
TrackerURL: local.TrackerURL,
|
|
||||||
IsActive: local.IsActive,
|
IsActive: local.IsActive,
|
||||||
IsSystem: local.IsSystem,
|
IsSystem: local.IsSystem,
|
||||||
CreatedAt: local.CreatedAt,
|
CreatedAt: local.CreatedAt,
|
||||||
@@ -154,7 +135,6 @@ 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,
|
||||||
@@ -167,7 +147,6 @@ 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,
|
||||||
@@ -177,30 +156,20 @@ 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,
|
||||||
LotCategory: item.LotCategory,
|
|
||||||
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,
|
||||||
LotCategory: local.LotCategory,
|
|
||||||
Price: local.Price,
|
Price: local.Price,
|
||||||
AvailableQty: local.AvailableQty,
|
|
||||||
Partnumbers: partnumbers,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,6 +197,8 @@ func ComponentToLocal(meta *models.LotMetadata) *LocalComponent {
|
|||||||
LotDescription: lotDesc,
|
LotDescription: lotDesc,
|
||||||
Category: category,
|
Category: category,
|
||||||
Model: meta.Model,
|
Model: meta.Model,
|
||||||
|
CurrentPrice: meta.CurrentPrice,
|
||||||
|
SyncedAt: time.Now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,6 +207,7 @@ func LocalToComponent(local *LocalComponent) *models.LotMetadata {
|
|||||||
return &models.LotMetadata{
|
return &models.LotMetadata{
|
||||||
LotName: local.LotName,
|
LotName: local.LotName,
|
||||||
Model: local.Model,
|
Model: local.Model,
|
||||||
|
CurrentPrice: local.CurrentPrice,
|
||||||
Lot: &models.Lot{
|
Lot: &models.Lot{
|
||||||
LotName: local.LotName,
|
LotName: local.LotName,
|
||||||
LotDescription: local.LotDescription,
|
LotDescription: local.LotDescription,
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
package localdb
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestPricelistItemToLocal_PreservesLotCategory(t *testing.T) {
|
|
||||||
item := &models.PricelistItem{
|
|
||||||
LotName: "CPU_A",
|
|
||||||
LotCategory: "CPU",
|
|
||||||
Price: 10,
|
|
||||||
}
|
|
||||||
|
|
||||||
local := PricelistItemToLocal(item, 123)
|
|
||||||
if local.LotCategory != "CPU" {
|
|
||||||
t.Fatalf("expected LotCategory=CPU, got %q", local.LotCategory)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLocalToPricelistItem_PreservesLotCategory(t *testing.T) {
|
|
||||||
local := &LocalPricelistItem{
|
|
||||||
LotName: "CPU_A",
|
|
||||||
LotCategory: "CPU",
|
|
||||||
Price: 10,
|
|
||||||
}
|
|
||||||
|
|
||||||
item := LocalToPricelistItem(local, 456)
|
|
||||||
if item.LotCategory != "CPU" {
|
|
||||||
t.Fatalf("expected LotCategory=CPU, got %q", item.LotCategory)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -3,9 +3,6 @@ package localdb
|
|||||||
import (
|
import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRunLocalMigrationsBackfillsExistingConfigurations(t *testing.T) {
|
func TestRunLocalMigrationsBackfillsExistingConfigurations(t *testing.T) {
|
||||||
@@ -73,183 +70,3 @@ 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunLocalMigrationsDeduplicatesConfigurationVersionsBySpecAndPrice(t *testing.T) {
|
|
||||||
dbPath := filepath.Join(t.TempDir(), "versions_dedup.db")
|
|
||||||
|
|
||||||
local, err := New(dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open localdb: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = local.Close() })
|
|
||||||
|
|
||||||
cfg := &LocalConfiguration{
|
|
||||||
UUID: "dedup-cfg",
|
|
||||||
Name: "Dedup",
|
|
||||||
Items: LocalConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 100}},
|
|
||||||
ServerCount: 1,
|
|
||||||
SyncStatus: "pending",
|
|
||||||
OriginalUsername: "tester",
|
|
||||||
IsActive: true,
|
|
||||||
}
|
|
||||||
if err := local.SaveConfiguration(cfg); err != nil {
|
|
||||||
t.Fatalf("save seed config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
baseV1Data, err := BuildConfigurationSnapshot(cfg)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build v1 snapshot: %v", err)
|
|
||||||
}
|
|
||||||
baseV1 := LocalConfigurationVersion{
|
|
||||||
ID: uuid.NewString(),
|
|
||||||
ConfigurationUUID: cfg.UUID,
|
|
||||||
VersionNo: 1,
|
|
||||||
Data: baseV1Data,
|
|
||||||
AppVersion: "test",
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
}
|
|
||||||
if err := local.DB().Create(&baseV1).Error; err != nil {
|
|
||||||
t.Fatalf("insert base v1: %v", err)
|
|
||||||
}
|
|
||||||
if err := local.DB().Model(&LocalConfiguration{}).
|
|
||||||
Where("uuid = ?", cfg.UUID).
|
|
||||||
Update("current_version_id", baseV1.ID).Error; err != nil {
|
|
||||||
t.Fatalf("set current_version_id to v1: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
v2 := LocalConfigurationVersion{
|
|
||||||
ID: uuid.NewString(),
|
|
||||||
ConfigurationUUID: cfg.UUID,
|
|
||||||
VersionNo: 2,
|
|
||||||
Data: baseV1.Data,
|
|
||||||
AppVersion: "test",
|
|
||||||
CreatedAt: time.Now().Add(1 * time.Second),
|
|
||||||
}
|
|
||||||
if err := local.DB().Create(&v2).Error; err != nil {
|
|
||||||
t.Fatalf("insert duplicate v2: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
modified := *cfg
|
|
||||||
modified.Items = LocalConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 100}}
|
|
||||||
total := modified.Items.Total()
|
|
||||||
modified.TotalPrice = &total
|
|
||||||
modified.UpdatedAt = time.Now()
|
|
||||||
v3Data, err := BuildConfigurationSnapshot(&modified)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build v3 snapshot: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
v3 := LocalConfigurationVersion{
|
|
||||||
ID: uuid.NewString(),
|
|
||||||
ConfigurationUUID: cfg.UUID,
|
|
||||||
VersionNo: 3,
|
|
||||||
Data: v3Data,
|
|
||||||
AppVersion: "test",
|
|
||||||
CreatedAt: time.Now().Add(2 * time.Second),
|
|
||||||
}
|
|
||||||
if err := local.DB().Create(&v3).Error; err != nil {
|
|
||||||
t.Fatalf("insert v3: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
v4 := LocalConfigurationVersion{
|
|
||||||
ID: uuid.NewString(),
|
|
||||||
ConfigurationUUID: cfg.UUID,
|
|
||||||
VersionNo: 4,
|
|
||||||
Data: v3Data,
|
|
||||||
AppVersion: "test",
|
|
||||||
CreatedAt: time.Now().Add(3 * time.Second),
|
|
||||||
}
|
|
||||||
if err := local.DB().Create(&v4).Error; err != nil {
|
|
||||||
t.Fatalf("insert duplicate v4: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := local.DB().Model(&LocalConfiguration{}).
|
|
||||||
Where("uuid = ?", cfg.UUID).
|
|
||||||
Update("current_version_id", v4.ID).Error; err != nil {
|
|
||||||
t.Fatalf("point current_version_id to duplicate v4: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := local.DB().Where("id = ?", "2026_02_19_configuration_versions_dedup_spec_price").
|
|
||||||
Delete(&LocalSchemaMigration{}).Error; err != nil {
|
|
||||||
t.Fatalf("delete dedup migration record: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := runLocalMigrations(local.DB()); err != nil {
|
|
||||||
t.Fatalf("rerun local migrations: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var versions []LocalConfigurationVersion
|
|
||||||
if err := local.DB().Where("configuration_uuid = ?", cfg.UUID).
|
|
||||||
Order("version_no ASC").
|
|
||||||
Find(&versions).Error; err != nil {
|
|
||||||
t.Fatalf("load versions after dedup: %v", err)
|
|
||||||
}
|
|
||||||
if len(versions) != 2 {
|
|
||||||
t.Fatalf("expected 2 versions after dedup, got %d", len(versions))
|
|
||||||
}
|
|
||||||
if versions[0].VersionNo != 1 || versions[1].VersionNo != 3 {
|
|
||||||
t.Fatalf("expected kept version numbers [1,3], got [%d,%d]", versions[0].VersionNo, versions[1].VersionNo)
|
|
||||||
}
|
|
||||||
|
|
||||||
var after LocalConfiguration
|
|
||||||
if err := local.DB().Where("uuid = ?", cfg.UUID).First(&after).Error; err != nil {
|
|
||||||
t.Fatalf("load config after dedup: %v", err)
|
|
||||||
}
|
|
||||||
if after.CurrentVersionID == nil || *after.CurrentVersionID != v3.ID {
|
|
||||||
t.Fatalf("expected current_version_id to point to kept latest version v3")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,20 +4,15 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/appstate"
|
|
||||||
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -42,49 +37,6 @@ type LocalDB struct {
|
|||||||
path string
|
path string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResetData clears local data tables while keeping connection settings.
|
|
||||||
// It does not drop schema or connection_settings.
|
|
||||||
func ResetData(dbPath string) error {
|
|
||||||
if strings.TrimSpace(dbPath) == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(dbPath); err != nil {
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return fmt.Errorf("stat local db: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
|
||||||
Logger: logger.Default.LogMode(logger.Silent),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("opening sqlite database: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Order does not matter because we use DELETEs without FK constraints in SQLite.
|
|
||||||
tables := []string{
|
|
||||||
"local_projects",
|
|
||||||
"local_configurations",
|
|
||||||
"local_configuration_versions",
|
|
||||||
"local_pricelists",
|
|
||||||
"local_pricelist_items",
|
|
||||||
"local_components",
|
|
||||||
"local_remote_migrations_applied",
|
|
||||||
"local_sync_guard_state",
|
|
||||||
"pending_changes",
|
|
||||||
"app_settings",
|
|
||||||
}
|
|
||||||
for _, table := range tables {
|
|
||||||
if err := db.Exec("DELETE FROM " + table).Error; err != nil {
|
|
||||||
return fmt.Errorf("clear %s: %w", table, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Info("local database data reset", "path", dbPath)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// New creates a new LocalDB instance
|
// New creates a new LocalDB instance
|
||||||
func New(dbPath string) (*LocalDB, error) {
|
func New(dbPath string) (*LocalDB, error) {
|
||||||
// Ensure directory exists
|
// Ensure directory exists
|
||||||
@@ -93,14 +45,6 @@ func New(dbPath string) (*LocalDB, error) {
|
|||||||
return nil, fmt.Errorf("creating data directory: %w", err)
|
return nil, fmt.Errorf("creating data directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfgPath, err := appstate.ResolveConfigPathNearDB("", dbPath); err == nil {
|
|
||||||
if _, err := appstate.EnsureRotatingLocalBackup(dbPath, cfgPath); err != nil {
|
|
||||||
return nil, fmt.Errorf("backup local data: %w", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return nil, fmt.Errorf("resolve config path: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||||
Logger: logger.Default.LogMode(logger.Silent),
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
})
|
})
|
||||||
@@ -108,39 +52,16 @@ func New(dbPath string) (*LocalDB, error) {
|
|||||||
return nil, fmt.Errorf("opening sqlite database: %w", err)
|
return nil, fmt.Errorf("opening sqlite database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ensureLocalProjectsTable(db); err != nil {
|
|
||||||
return nil, fmt.Errorf("ensure local_projects table: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Preflight: ensure local_projects has non-null UUIDs before AutoMigrate rebuilds tables.
|
|
||||||
if db.Migrator().HasTable(&LocalProject{}) {
|
|
||||||
if !db.Migrator().HasColumn(&LocalProject{}, "uuid") {
|
|
||||||
if err := db.Exec(`ALTER TABLE local_projects ADD COLUMN uuid TEXT`).Error; err != nil {
|
|
||||||
return nil, fmt.Errorf("adding local_projects.uuid: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var ids []uint
|
|
||||||
if err := db.Raw(`SELECT id FROM local_projects WHERE uuid IS NULL OR uuid = ''`).Scan(&ids).Error; err != nil {
|
|
||||||
return nil, fmt.Errorf("finding local_projects without uuid: %w", err)
|
|
||||||
}
|
|
||||||
for _, id := range ids {
|
|
||||||
if err := db.Exec(`UPDATE local_projects SET uuid = ? WHERE id = ?`, uuidpkg.New().String(), id).Error; err != nil {
|
|
||||||
return nil, fmt.Errorf("backfilling local_projects.uuid: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
||||||
@@ -157,38 +78,6 @@ func New(dbPath string) (*LocalDB, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ensureLocalProjectsTable(db *gorm.DB) error {
|
|
||||||
if db.Migrator().HasTable(&LocalProject{}) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := db.Exec(`
|
|
||||||
CREATE TABLE local_projects (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
uuid TEXT NOT NULL UNIQUE,
|
|
||||||
server_id INTEGER NULL,
|
|
||||||
owner_username TEXT NOT NULL,
|
|
||||||
code TEXT NOT NULL,
|
|
||||||
variant TEXT NOT NULL DEFAULT '',
|
|
||||||
name TEXT 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,
|
|
||||||
synced_at DATETIME NULL,
|
|
||||||
sync_status TEXT DEFAULT 'local'
|
|
||||||
)`).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_owner_username ON local_projects(owner_username)`).Error
|
|
||||||
_ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_is_active ON local_projects(is_active)`).Error
|
|
||||||
_ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_is_system ON local_projects(is_system)`).Error
|
|
||||||
_ = db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code_variant ON local_projects(code, variant)`).Error
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// HasSettings returns true if connection settings exist
|
// HasSettings returns true if connection settings exist
|
||||||
func (l *LocalDB) HasSettings() bool {
|
func (l *LocalDB) HasSettings() bool {
|
||||||
var count int64
|
var count int64
|
||||||
@@ -252,23 +141,19 @@ func (l *LocalDB) GetDSN() (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := mysqlDriver.NewConfig()
|
// Add aggressive timeouts for offline-first architecture
|
||||||
cfg.User = settings.User
|
// timeout: connection establishment timeout (3s)
|
||||||
cfg.Passwd = settings.PasswordEncrypted // Contains decrypted password after GetSettings
|
// readTimeout: I/O read timeout (3s)
|
||||||
cfg.Net = "tcp"
|
// writeTimeout: I/O write timeout (3s)
|
||||||
cfg.Addr = net.JoinHostPort(settings.Host, strconv.Itoa(settings.Port))
|
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=3s&readTimeout=3s&writeTimeout=3s",
|
||||||
cfg.DBName = settings.Database
|
settings.User,
|
||||||
cfg.ParseTime = true
|
settings.PasswordEncrypted, // Contains decrypted password after GetSettings
|
||||||
cfg.Loc = time.Local
|
settings.Host,
|
||||||
// Add aggressive timeouts for offline-first architecture.
|
settings.Port,
|
||||||
cfg.Timeout = 3 * time.Second
|
settings.Database,
|
||||||
cfg.ReadTimeout = 3 * time.Second
|
)
|
||||||
cfg.WriteTimeout = 3 * time.Second
|
|
||||||
cfg.Params = map[string]string{
|
|
||||||
"charset": "utf8mb4",
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfg.FormatDSN(), nil
|
return dsn, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DB returns the underlying gorm.DB for advanced operations
|
// DB returns the underlying gorm.DB for advanced operations
|
||||||
@@ -363,8 +248,7 @@ func (l *LocalDB) EnsureDefaultProject(ownerUsername string) (*LocalProject, err
|
|||||||
project = &LocalProject{
|
project = &LocalProject{
|
||||||
UUID: uuidpkg.NewString(),
|
UUID: uuidpkg.NewString(),
|
||||||
OwnerUsername: "",
|
OwnerUsername: "",
|
||||||
Code: "Без проекта",
|
Name: "Без проекта",
|
||||||
Name: ptrString("Без проекта"),
|
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
IsSystem: true,
|
IsSystem: true,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
@@ -392,8 +276,7 @@ func (l *LocalDB) ConsolidateSystemProjects() (int64, error) {
|
|||||||
canonical = LocalProject{
|
canonical = LocalProject{
|
||||||
UUID: uuidpkg.NewString(),
|
UUID: uuidpkg.NewString(),
|
||||||
OwnerUsername: "",
|
OwnerUsername: "",
|
||||||
Code: "Без проекта",
|
Name: "Без проекта",
|
||||||
Name: ptrString("Без проекта"),
|
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
IsSystem: true,
|
IsSystem: true,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
@@ -474,10 +357,6 @@ WHERE (
|
|||||||
return tx.RowsAffected, tx.Error
|
return tx.RowsAffected, tx.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func ptrString(value string) *string {
|
|
||||||
return &value
|
|
||||||
}
|
|
||||||
|
|
||||||
// BackfillConfigurationProjects ensures every configuration has project_uuid set.
|
// BackfillConfigurationProjects ensures every configuration has project_uuid set.
|
||||||
// If missing, it assigns system project "Без проекта" for configuration owner.
|
// If missing, it assigns system project "Без проекта" for configuration owner.
|
||||||
func (l *LocalDB) BackfillConfigurationProjects(defaultOwner string) error {
|
func (l *LocalDB) BackfillConfigurationProjects(defaultOwner string) error {
|
||||||
@@ -531,55 +410,6 @@ 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("local_configurations.is_active = ?", true)
|
|
||||||
case "archived":
|
|
||||||
query = query.Where("local_configurations.is_active = ?", false)
|
|
||||||
case "all", "":
|
|
||||||
// no-op
|
|
||||||
default:
|
|
||||||
query = query.Where("local_configurations.is_active = ?", true)
|
|
||||||
}
|
|
||||||
|
|
||||||
search = strings.TrimSpace(search)
|
|
||||||
if search != "" {
|
|
||||||
needle := "%" + strings.ToLower(search) + "%"
|
|
||||||
hasProjectsTable := l.db.Migrator().HasTable(&LocalProject{})
|
|
||||||
hasServerModel := l.db.Migrator().HasColumn(&LocalConfiguration{}, "server_model")
|
|
||||||
|
|
||||||
conditions := []string{"LOWER(local_configurations.name) LIKE ?"}
|
|
||||||
args := []interface{}{needle}
|
|
||||||
|
|
||||||
if hasProjectsTable {
|
|
||||||
query = query.Joins("LEFT JOIN local_projects lp ON lp.uuid = local_configurations.project_uuid")
|
|
||||||
conditions = append(conditions, "LOWER(COALESCE(lp.name, '')) LIKE ?")
|
|
||||||
args = append(args, needle)
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasServerModel {
|
|
||||||
conditions = append(conditions, "LOWER(COALESCE(local_configurations.server_model, '')) LIKE ?")
|
|
||||||
args = append(args, needle)
|
|
||||||
}
|
|
||||||
|
|
||||||
query = query.Where(strings.Join(conditions, " OR "), args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
var total int64
|
|
||||||
if err := query.Count(&total).Error; err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var configs []LocalConfiguration
|
|
||||||
if err := query.Order("local_configurations.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)
|
||||||
@@ -645,13 +475,6 @@ 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
|
||||||
@@ -692,24 +515,7 @@ 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.
|
if err := l.db.Order("created_at DESC").First(&pricelist).Error; err != nil {
|
||||||
Where("source = ?", "estimate").
|
|
||||||
Where("EXISTS (SELECT 1 FROM local_pricelist_items WHERE local_pricelist_items.pricelist_id = local_pricelists.id)").
|
|
||||||
Order("created_at DESC, id 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).
|
|
||||||
Where("EXISTS (SELECT 1 FROM local_pricelist_items WHERE local_pricelist_items.pricelist_id = local_pricelists.id)").
|
|
||||||
Order("created_at DESC, id DESC").
|
|
||||||
First(&pricelist).Error; err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &pricelist, nil
|
return &pricelist, nil
|
||||||
@@ -724,24 +530,6 @@ 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
|
||||||
@@ -753,17 +541,7 @@ 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.Clauses(clause.OnConflict{
|
return l.db.Save(pricelist).Error
|
||||||
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
|
||||||
@@ -782,17 +560,6 @@ func (l *LocalDB) CountLocalPricelistItems(pricelistID uint) int64 {
|
|||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountLocalPricelistItemsWithEmptyCategory returns the number of items for a pricelist with missing lot_category.
|
|
||||||
func (l *LocalDB) CountLocalPricelistItemsWithEmptyCategory(pricelistID uint) (int64, error) {
|
|
||||||
var count int64
|
|
||||||
if err := l.db.Model(&LocalPricelistItem{}).
|
|
||||||
Where("pricelist_id = ? AND (lot_category IS NULL OR TRIM(lot_category) = '')", pricelistID).
|
|
||||||
Count(&count).Error; err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return count, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SaveLocalPricelistItems saves pricelist items to local SQLite
|
// SaveLocalPricelistItems saves pricelist items to local SQLite
|
||||||
func (l *LocalDB) SaveLocalPricelistItems(items []LocalPricelistItem) error {
|
func (l *LocalDB) SaveLocalPricelistItems(items []LocalPricelistItem) error {
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
@@ -813,30 +580,6 @@ func (l *LocalDB) SaveLocalPricelistItems(items []LocalPricelistItem) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReplaceLocalPricelistItems atomically replaces all items for a pricelist.
|
|
||||||
func (l *LocalDB) ReplaceLocalPricelistItems(pricelistID uint, items []LocalPricelistItem) error {
|
|
||||||
return l.db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := tx.Where("pricelist_id = ?", pricelistID).Delete(&LocalPricelistItem{}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(items) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
batchSize := 500
|
|
||||||
for i := 0; i < len(items); i += batchSize {
|
|
||||||
end := i + batchSize
|
|
||||||
if end > len(items) {
|
|
||||||
end = len(items)
|
|
||||||
}
|
|
||||||
if err := tx.CreateInBatches(items[i:end], batchSize).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetLocalPricelistItems returns items for a local pricelist
|
// GetLocalPricelistItems returns items for a local pricelist
|
||||||
func (l *LocalDB) GetLocalPricelistItems(pricelistID uint) ([]LocalPricelistItem, error) {
|
func (l *LocalDB) GetLocalPricelistItems(pricelistID uint) ([]LocalPricelistItem, error) {
|
||||||
var items []LocalPricelistItem
|
var items []LocalPricelistItem
|
||||||
@@ -856,61 +599,12 @@ func (l *LocalDB) GetLocalPriceForLot(pricelistID uint, lotName string) (float64
|
|||||||
return item.Price, nil
|
return item.Price, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLocalLotCategoriesByServerPricelistID returns lot_category for each lot_name from a local pricelist resolved by server ID.
|
|
||||||
// Missing lots are not included in the map; caller is responsible for strict validation.
|
|
||||||
func (l *LocalDB) GetLocalLotCategoriesByServerPricelistID(serverPricelistID uint, lotNames []string) (map[string]string, error) {
|
|
||||||
result := make(map[string]string, len(lotNames))
|
|
||||||
if serverPricelistID == 0 || len(lotNames) == 0 {
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
localPL, err := l.GetLocalPricelistByServerID(serverPricelistID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
type row struct {
|
|
||||||
LotName string `gorm:"column:lot_name"`
|
|
||||||
LotCategory string `gorm:"column:lot_category"`
|
|
||||||
}
|
|
||||||
var rows []row
|
|
||||||
if err := l.db.Model(&LocalPricelistItem{}).
|
|
||||||
Select("lot_name, lot_category").
|
|
||||||
Where("pricelist_id = ? AND lot_name IN ?", localPL.ID, lotNames).
|
|
||||||
Find(&rows).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for _, r := range rows {
|
|
||||||
result[r.LotName] = r.LotCategory
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarkPricelistAsUsed marks a pricelist as used by a configuration
|
// MarkPricelistAsUsed marks a pricelist as used by a configuration
|
||||||
func (l *LocalDB) MarkPricelistAsUsed(pricelistID uint, isUsed bool) error {
|
func (l *LocalDB) MarkPricelistAsUsed(pricelistID uint, isUsed bool) error {
|
||||||
return l.db.Model(&LocalPricelist{}).Where("id = ?", pricelistID).
|
return l.db.Model(&LocalPricelist{}).Where("id = ?", pricelistID).
|
||||||
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
|
||||||
@@ -921,47 +615,6 @@ 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
|
||||||
@@ -1048,210 +701,3 @@ func (l *LocalDB) PurgeOrphanConfigurationPendingChanges() (int64, error) {
|
|||||||
func (l *LocalDB) GetPendingCount() int64 {
|
func (l *LocalDB) GetPendingCount() int64 {
|
||||||
return l.CountPendingChanges()
|
return l.CountPendingChanges()
|
||||||
}
|
}
|
||||||
|
|
||||||
// RepairPendingChanges attempts to fix errored pending changes by validating and correcting data.
|
|
||||||
// Returns the number of changes repaired and a list of errors that couldn't be fixed.
|
|
||||||
func (l *LocalDB) RepairPendingChanges() (int, []string, error) {
|
|
||||||
var erroredChanges []PendingChange
|
|
||||||
if err := l.db.Where("last_error != ?", "").Find(&erroredChanges).Error; err != nil {
|
|
||||||
return 0, nil, fmt.Errorf("fetching errored changes: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(erroredChanges) == 0 {
|
|
||||||
return 0, nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
repaired := 0
|
|
||||||
var remainingErrors []string
|
|
||||||
|
|
||||||
for _, change := range erroredChanges {
|
|
||||||
var repairErr error
|
|
||||||
switch change.EntityType {
|
|
||||||
case "project":
|
|
||||||
repairErr = l.repairProjectChange(&change)
|
|
||||||
case "configuration":
|
|
||||||
repairErr = l.repairConfigurationChange(&change)
|
|
||||||
default:
|
|
||||||
repairErr = fmt.Errorf("unknown entity type: %s", change.EntityType)
|
|
||||||
}
|
|
||||||
|
|
||||||
if repairErr != nil {
|
|
||||||
remainingErrors = append(remainingErrors, fmt.Sprintf("%s %s %s: %v",
|
|
||||||
change.Operation, change.EntityType, change.EntityUUID[:8], repairErr))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear error and reset attempts
|
|
||||||
if err := l.db.Model(&PendingChange{}).Where("id = ?", change.ID).Updates(map[string]interface{}{
|
|
||||||
"last_error": "",
|
|
||||||
"attempts": 0,
|
|
||||||
}).Error; err != nil {
|
|
||||||
remainingErrors = append(remainingErrors, fmt.Sprintf("clearing error for %s: %v", change.EntityUUID[:8], err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
repaired++
|
|
||||||
}
|
|
||||||
|
|
||||||
return repaired, remainingErrors, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// repairProjectChange validates and fixes project data.
|
|
||||||
// Note: This only validates local data. Server-side conflicts (like duplicate code+variant)
|
|
||||||
// are handled by sync service layer with deduplication logic.
|
|
||||||
func (l *LocalDB) repairProjectChange(change *PendingChange) error {
|
|
||||||
project, err := l.GetProjectByUUID(change.EntityUUID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("project not found locally: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
modified := false
|
|
||||||
|
|
||||||
// Fix Code: must be non-empty
|
|
||||||
if strings.TrimSpace(project.Code) == "" {
|
|
||||||
if project.Name != nil && strings.TrimSpace(*project.Name) != "" {
|
|
||||||
project.Code = strings.TrimSpace(*project.Name)
|
|
||||||
} else {
|
|
||||||
project.Code = project.UUID[:8]
|
|
||||||
}
|
|
||||||
modified = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fix Name: use Code if empty
|
|
||||||
if project.Name == nil || strings.TrimSpace(*project.Name) == "" {
|
|
||||||
name := project.Code
|
|
||||||
project.Name = &name
|
|
||||||
modified = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fix OwnerUsername: must be non-empty
|
|
||||||
if strings.TrimSpace(project.OwnerUsername) == "" {
|
|
||||||
project.OwnerUsername = l.GetDBUser()
|
|
||||||
if project.OwnerUsername == "" {
|
|
||||||
return fmt.Errorf("cannot determine owner username")
|
|
||||||
}
|
|
||||||
modified = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for local duplicates with same (code, variant)
|
|
||||||
var duplicate LocalProject
|
|
||||||
err = l.db.Where("code = ? AND variant = ? AND uuid != ?", project.Code, project.Variant, project.UUID).
|
|
||||||
First(&duplicate).Error
|
|
||||||
if err == nil {
|
|
||||||
// Found local duplicate - deduplicate by appending UUID suffix to variant
|
|
||||||
if project.Variant == "" {
|
|
||||||
project.Variant = project.UUID[:8]
|
|
||||||
} else {
|
|
||||||
project.Variant = project.Variant + "-" + project.UUID[:8]
|
|
||||||
}
|
|
||||||
modified = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if modified {
|
|
||||||
if err := l.SaveProject(project); err != nil {
|
|
||||||
return fmt.Errorf("saving repaired project: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// repairConfigurationChange validates and fixes configuration data
|
|
||||||
func (l *LocalDB) repairConfigurationChange(change *PendingChange) error {
|
|
||||||
config, err := l.GetConfigurationByUUID(change.EntityUUID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("configuration not found locally: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
modified := false
|
|
||||||
|
|
||||||
// Check if referenced project exists
|
|
||||||
if config.ProjectUUID != nil && *config.ProjectUUID != "" {
|
|
||||||
_, err := l.GetProjectByUUID(*config.ProjectUUID)
|
|
||||||
if err != nil {
|
|
||||||
// Project doesn't exist locally - use default system project
|
|
||||||
systemProject, sysErr := l.EnsureDefaultProject(config.OriginalUsername)
|
|
||||||
if sysErr != nil {
|
|
||||||
return fmt.Errorf("getting system project: %w", sysErr)
|
|
||||||
}
|
|
||||||
config.ProjectUUID = &systemProject.UUID
|
|
||||||
modified = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if modified {
|
|
||||||
if err := l.SaveConfiguration(config); err != nil {
|
|
||||||
return fmt.Errorf("saving repaired configuration: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ func TestRunLocalMigrationsBackfillsDefaultProject(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get system project: %v", err)
|
t.Fatalf("get system project: %v", err)
|
||||||
}
|
}
|
||||||
if project.Name == nil || *project.Name != "Без проекта" {
|
if project.Name != "Без проекта" {
|
||||||
t.Fatalf("expected system project name, got %v", project.Name)
|
t.Fatalf("expected system project name, got %q", project.Name)
|
||||||
}
|
}
|
||||||
if !project.IsSystem {
|
if !project.IsSystem {
|
||||||
t.Fatalf("expected system project flag")
|
t.Fatalf("expected system project flag")
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -43,71 +42,6 @@ var localMigrations = []localMigration{
|
|||||||
name: "Create default projects and attach existing configurations",
|
name: "Create default projects and attach existing configurations",
|
||||||
run: backfillProjectsForConfigurations,
|
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,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2026_02_09_drop_component_unused_fields",
|
|
||||||
name: "Remove current_price and synced_at from local_components (unused fields)",
|
|
||||||
run: dropComponentUnusedFields,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2026_02_09_add_warehouse_competitor_pricelists",
|
|
||||||
name: "Add warehouse_pricelist_id and competitor_pricelist_id to local_configurations",
|
|
||||||
run: addWarehouseCompetitorPriceLists,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2026_02_11_local_pricelist_item_category",
|
|
||||||
name: "Add lot_category to local_pricelist_items and create indexes",
|
|
||||||
run: addLocalPricelistItemCategoryAndIndexes,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2026_02_11_local_config_article",
|
|
||||||
name: "Add article to local_configurations",
|
|
||||||
run: addLocalConfigurationArticle,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2026_02_11_local_config_server_model",
|
|
||||||
name: "Add server_model to local_configurations",
|
|
||||||
run: addLocalConfigurationServerModel,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2026_02_11_local_config_support_code",
|
|
||||||
name: "Add support_code to local_configurations",
|
|
||||||
run: addLocalConfigurationSupportCode,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2026_02_13_local_project_code",
|
|
||||||
name: "Add project code to local_projects and backfill",
|
|
||||||
run: addLocalProjectCode,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2026_02_13_local_project_variant",
|
|
||||||
name: "Add project variant to local_projects and backfill",
|
|
||||||
run: addLocalProjectVariant,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2026_02_13_local_project_name_nullable",
|
|
||||||
name: "Allow NULL project names in local_projects",
|
|
||||||
run: allowLocalProjectNameNull,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2026_02_19_configuration_versions_dedup_spec_price",
|
|
||||||
name: "Deduplicate configuration revisions by spec+price",
|
|
||||||
run: deduplicateConfigurationVersionsBySpecAndPrice,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func runLocalMigrations(db *gorm.DB) error {
|
func runLocalMigrations(db *gorm.DB) error {
|
||||||
@@ -244,8 +178,7 @@ func ensureDefaultProjectTx(tx *gorm.DB, ownerUsername string) (*LocalProject, e
|
|||||||
project = LocalProject{
|
project = LocalProject{
|
||||||
UUID: uuid.NewString(),
|
UUID: uuid.NewString(),
|
||||||
OwnerUsername: ownerUsername,
|
OwnerUsername: ownerUsername,
|
||||||
Code: "Без проекта",
|
Name: "Без проекта",
|
||||||
Name: ptrString("Без проекта"),
|
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
IsSystem: true,
|
IsSystem: true,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
@@ -259,550 +192,9 @@ func ensureDefaultProjectTx(tx *gorm.DB, ownerUsername string) (*LocalProject, e
|
|||||||
return &project, nil
|
return &project, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func addLocalProjectCode(tx *gorm.DB) error {
|
|
||||||
if err := tx.Exec(`ALTER TABLE local_projects ADD COLUMN code TEXT`).Error; err != nil {
|
|
||||||
if !strings.Contains(strings.ToLower(err.Error()), "duplicate") &&
|
|
||||||
!strings.Contains(strings.ToLower(err.Error()), "exists") {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drop unique index if it already exists to allow de-duplication updates.
|
|
||||||
if err := tx.Exec(`DROP INDEX IF EXISTS idx_local_projects_code`).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy code from current project name.
|
|
||||||
if err := tx.Exec(`
|
|
||||||
UPDATE local_projects
|
|
||||||
SET code = TRIM(COALESCE(name, ''))`).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure any remaining blanks have a unique fallback.
|
|
||||||
if err := tx.Exec(`
|
|
||||||
UPDATE local_projects
|
|
||||||
SET code = 'P-' || uuid
|
|
||||||
WHERE code IS NULL OR TRIM(code) = ''`).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// De-duplicate codes: OPS-1948-2, OPS-1948-3...
|
|
||||||
if err := tx.Exec(`
|
|
||||||
WITH ranked AS (
|
|
||||||
SELECT id, code,
|
|
||||||
ROW_NUMBER() OVER (PARTITION BY code ORDER BY id) AS rn
|
|
||||||
FROM local_projects
|
|
||||||
)
|
|
||||||
UPDATE local_projects
|
|
||||||
SET code = code || '-' || (SELECT rn FROM ranked WHERE ranked.id = local_projects.id)
|
|
||||||
WHERE id IN (SELECT id FROM ranked WHERE rn > 1)`).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create unique index for project codes (ignore if exists).
|
|
||||||
if err := tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code ON local_projects(code)`).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func addLocalProjectVariant(tx *gorm.DB) error {
|
|
||||||
if err := tx.Exec(`ALTER TABLE local_projects ADD COLUMN variant TEXT NOT NULL DEFAULT ''`).Error; err != nil {
|
|
||||||
if !strings.Contains(strings.ToLower(err.Error()), "duplicate") &&
|
|
||||||
!strings.Contains(strings.ToLower(err.Error()), "exists") {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drop legacy code index if present.
|
|
||||||
if err := tx.Exec(`DROP INDEX IF EXISTS idx_local_projects_code`).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset code from name and clear variant.
|
|
||||||
if err := tx.Exec(`
|
|
||||||
UPDATE local_projects
|
|
||||||
SET code = TRIM(COALESCE(name, '')),
|
|
||||||
variant = ''`).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// De-duplicate by assigning variant numbers: 2,3...
|
|
||||||
if err := tx.Exec(`
|
|
||||||
WITH ranked AS (
|
|
||||||
SELECT id, code,
|
|
||||||
ROW_NUMBER() OVER (PARTITION BY code ORDER BY id) AS rn
|
|
||||||
FROM local_projects
|
|
||||||
)
|
|
||||||
UPDATE local_projects
|
|
||||||
SET variant = CASE
|
|
||||||
WHEN (SELECT rn FROM ranked WHERE ranked.id = local_projects.id) = 1 THEN ''
|
|
||||||
ELSE '-' || CAST((SELECT rn FROM ranked WHERE ranked.id = local_projects.id) AS TEXT)
|
|
||||||
END`).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code_variant ON local_projects(code, variant)`).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func allowLocalProjectNameNull(tx *gorm.DB) error {
|
|
||||||
if err := tx.Exec(`ALTER TABLE local_projects RENAME TO local_projects_old`).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Exec(`
|
|
||||||
CREATE TABLE local_projects (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
uuid TEXT NOT NULL UNIQUE,
|
|
||||||
server_id INTEGER NULL,
|
|
||||||
owner_username TEXT NOT NULL,
|
|
||||||
code TEXT NOT NULL,
|
|
||||||
variant TEXT NOT NULL DEFAULT '',
|
|
||||||
name TEXT 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,
|
|
||||||
synced_at DATETIME NULL,
|
|
||||||
sync_status TEXT DEFAULT 'local'
|
|
||||||
)`).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = tx.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_owner_username ON local_projects(owner_username)`).Error
|
|
||||||
_ = tx.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_is_active ON local_projects(is_active)`).Error
|
|
||||||
_ = tx.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_is_system ON local_projects(is_system)`).Error
|
|
||||||
_ = tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code_variant ON local_projects(code, variant)`).Error
|
|
||||||
|
|
||||||
if err := tx.Exec(`
|
|
||||||
INSERT INTO local_projects (id, uuid, server_id, owner_username, code, variant, name, tracker_url, is_active, is_system, created_at, updated_at, synced_at, sync_status)
|
|
||||||
SELECT id, uuid, server_id, owner_username, code, variant, name, tracker_url, is_active, is_system, created_at, updated_at, synced_at, sync_status
|
|
||||||
FROM local_projects_old`).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = tx.Exec(`DROP TABLE local_projects_old`).Error
|
|
||||||
return 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 deduplicateConfigurationVersionsBySpecAndPrice(tx *gorm.DB) error {
|
|
||||||
var configs []LocalConfiguration
|
|
||||||
if err := tx.Select("uuid", "current_version_id").Find(&configs).Error; err != nil {
|
|
||||||
return fmt.Errorf("load configurations for revision deduplication: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var removedTotal int
|
|
||||||
for i := range configs {
|
|
||||||
cfg := configs[i]
|
|
||||||
|
|
||||||
var versions []LocalConfigurationVersion
|
|
||||||
if err := tx.Where("configuration_uuid = ?", cfg.UUID).
|
|
||||||
Order("version_no ASC, created_at ASC").
|
|
||||||
Find(&versions).Error; err != nil {
|
|
||||||
return fmt.Errorf("load versions for %s: %w", cfg.UUID, err)
|
|
||||||
}
|
|
||||||
if len(versions) < 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteIDs := make([]string, 0)
|
|
||||||
deleteSet := make(map[string]struct{})
|
|
||||||
kept := make([]LocalConfigurationVersion, 0, len(versions))
|
|
||||||
var prevKey string
|
|
||||||
hasPrev := false
|
|
||||||
|
|
||||||
for _, version := range versions {
|
|
||||||
snapshotCfg, err := DecodeConfigurationSnapshot(version.Data)
|
|
||||||
if err != nil {
|
|
||||||
// Keep malformed snapshots untouched and reset chain to avoid accidental removals.
|
|
||||||
kept = append(kept, version)
|
|
||||||
hasPrev = false
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
key, err := BuildConfigurationSpecPriceFingerprint(snapshotCfg)
|
|
||||||
if err != nil {
|
|
||||||
kept = append(kept, version)
|
|
||||||
hasPrev = false
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if !hasPrev || key != prevKey {
|
|
||||||
kept = append(kept, version)
|
|
||||||
prevKey = key
|
|
||||||
hasPrev = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteIDs = append(deleteIDs, version.ID)
|
|
||||||
deleteSet[version.ID] = struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(deleteIDs) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Where("id IN ?", deleteIDs).Delete(&LocalConfigurationVersion{}).Error; err != nil {
|
|
||||||
return fmt.Errorf("delete duplicate versions for %s: %w", cfg.UUID, err)
|
|
||||||
}
|
|
||||||
removedTotal += len(deleteIDs)
|
|
||||||
|
|
||||||
latestKeptID := kept[len(kept)-1].ID
|
|
||||||
if cfg.CurrentVersionID == nil || *cfg.CurrentVersionID == "" {
|
|
||||||
if err := tx.Model(&LocalConfiguration{}).
|
|
||||||
Where("uuid = ?", cfg.UUID).
|
|
||||||
Update("current_version_id", latestKeptID).Error; err != nil {
|
|
||||||
return fmt.Errorf("set missing current_version_id for %s: %w", cfg.UUID, err)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, deleted := deleteSet[*cfg.CurrentVersionID]; deleted {
|
|
||||||
if err := tx.Model(&LocalConfiguration{}).
|
|
||||||
Where("uuid = ?", cfg.UUID).
|
|
||||||
Update("current_version_id", latestKeptID).Error; err != nil {
|
|
||||||
return fmt.Errorf("repair current_version_id for %s: %w", cfg.UUID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if removedTotal > 0 {
|
|
||||||
slog.Info("deduplicated configuration revisions", "removed_versions", removedTotal)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
func dropComponentUnusedFields(tx *gorm.DB) error {
|
|
||||||
// Check if columns exist
|
|
||||||
type columnInfo struct {
|
|
||||||
Name string `gorm:"column:name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var columns []columnInfo
|
|
||||||
if err := tx.Raw(`
|
|
||||||
SELECT name FROM pragma_table_info('local_components')
|
|
||||||
WHERE name IN ('current_price', 'synced_at')
|
|
||||||
`).Scan(&columns).Error; err != nil {
|
|
||||||
return fmt.Errorf("check columns existence: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(columns) == 0 {
|
|
||||||
slog.Info("unused fields already removed from local_components")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SQLite: recreate table without current_price and synced_at
|
|
||||||
if err := tx.Exec(`
|
|
||||||
CREATE TABLE local_components_new (
|
|
||||||
lot_name TEXT PRIMARY KEY,
|
|
||||||
lot_description TEXT,
|
|
||||||
category TEXT,
|
|
||||||
model TEXT
|
|
||||||
)
|
|
||||||
`).Error; err != nil {
|
|
||||||
return fmt.Errorf("create new local_components table: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Exec(`
|
|
||||||
INSERT INTO local_components_new (lot_name, lot_description, category, model)
|
|
||||||
SELECT lot_name, lot_description, category, model
|
|
||||||
FROM local_components
|
|
||||||
`).Error; err != nil {
|
|
||||||
return fmt.Errorf("copy data to new table: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Exec(`DROP TABLE local_components`).Error; err != nil {
|
|
||||||
return fmt.Errorf("drop old table: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Exec(`ALTER TABLE local_components_new RENAME TO local_components`).Error; err != nil {
|
|
||||||
return fmt.Errorf("rename new table: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Info("dropped current_price and synced_at columns from local_components")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func addWarehouseCompetitorPriceLists(tx *gorm.DB) error {
|
|
||||||
// Check if columns exist
|
|
||||||
type columnInfo struct {
|
|
||||||
Name string `gorm:"column:name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var columns []columnInfo
|
|
||||||
if err := tx.Raw(`
|
|
||||||
SELECT name FROM pragma_table_info('local_configurations')
|
|
||||||
WHERE name IN ('warehouse_pricelist_id', 'competitor_pricelist_id')
|
|
||||||
`).Scan(&columns).Error; err != nil {
|
|
||||||
return fmt.Errorf("check columns existence: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(columns) == 2 {
|
|
||||||
slog.Info("warehouse and competitor pricelist columns already exist")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add columns if they don't exist
|
|
||||||
if err := tx.Exec(`
|
|
||||||
ALTER TABLE local_configurations
|
|
||||||
ADD COLUMN warehouse_pricelist_id INTEGER
|
|
||||||
`).Error; err != nil {
|
|
||||||
// Column might already exist, ignore
|
|
||||||
if !strings.Contains(err.Error(), "duplicate column") {
|
|
||||||
return fmt.Errorf("add warehouse_pricelist_id column: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Exec(`
|
|
||||||
ALTER TABLE local_configurations
|
|
||||||
ADD COLUMN competitor_pricelist_id INTEGER
|
|
||||||
`).Error; err != nil {
|
|
||||||
// Column might already exist, ignore
|
|
||||||
if !strings.Contains(err.Error(), "duplicate column") {
|
|
||||||
return fmt.Errorf("add competitor_pricelist_id column: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create indexes
|
|
||||||
if err := tx.Exec(`
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_local_configurations_warehouse_pricelist
|
|
||||||
ON local_configurations(warehouse_pricelist_id)
|
|
||||||
`).Error; err != nil {
|
|
||||||
return fmt.Errorf("create warehouse pricelist index: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Exec(`
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_local_configurations_competitor_pricelist
|
|
||||||
ON local_configurations(competitor_pricelist_id)
|
|
||||||
`).Error; err != nil {
|
|
||||||
return fmt.Errorf("create competitor pricelist index: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Info("added warehouse and competitor pricelist fields to local_configurations")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func addLocalPricelistItemCategoryAndIndexes(tx *gorm.DB) error {
|
|
||||||
type columnInfo struct {
|
|
||||||
Name string `gorm:"column:name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var columns []columnInfo
|
|
||||||
if err := tx.Raw(`
|
|
||||||
SELECT name FROM pragma_table_info('local_pricelist_items')
|
|
||||||
WHERE name IN ('lot_category')
|
|
||||||
`).Scan(&columns).Error; err != nil {
|
|
||||||
return fmt.Errorf("check local_pricelist_items(lot_category) existence: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(columns) == 0 {
|
|
||||||
if err := tx.Exec(`
|
|
||||||
ALTER TABLE local_pricelist_items
|
|
||||||
ADD COLUMN lot_category TEXT
|
|
||||||
`).Error; err != nil {
|
|
||||||
return fmt.Errorf("add local_pricelist_items.lot_category: %w", err)
|
|
||||||
}
|
|
||||||
slog.Info("added lot_category to local_pricelist_items")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Exec(`
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_local_pricelist_items_pricelist_lot
|
|
||||||
ON local_pricelist_items(pricelist_id, lot_name)
|
|
||||||
`).Error; err != nil {
|
|
||||||
return fmt.Errorf("ensure idx_local_pricelist_items_pricelist_lot: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Exec(`
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_local_pricelist_items_lot_category
|
|
||||||
ON local_pricelist_items(lot_category)
|
|
||||||
`).Error; err != nil {
|
|
||||||
return fmt.Errorf("ensure idx_local_pricelist_items_lot_category: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func addLocalConfigurationArticle(tx *gorm.DB) error {
|
|
||||||
type columnInfo struct {
|
|
||||||
Name string `gorm:"column:name"`
|
|
||||||
}
|
|
||||||
var columns []columnInfo
|
|
||||||
if err := tx.Raw(`
|
|
||||||
SELECT name FROM pragma_table_info('local_configurations')
|
|
||||||
WHERE name IN ('article')
|
|
||||||
`).Scan(&columns).Error; err != nil {
|
|
||||||
return fmt.Errorf("check local_configurations(article) existence: %w", err)
|
|
||||||
}
|
|
||||||
if len(columns) == 0 {
|
|
||||||
if err := tx.Exec(`
|
|
||||||
ALTER TABLE local_configurations
|
|
||||||
ADD COLUMN article TEXT
|
|
||||||
`).Error; err != nil {
|
|
||||||
return fmt.Errorf("add local_configurations.article: %w", err)
|
|
||||||
}
|
|
||||||
slog.Info("added article to local_configurations")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func addLocalConfigurationServerModel(tx *gorm.DB) error {
|
|
||||||
type columnInfo struct {
|
|
||||||
Name string `gorm:"column:name"`
|
|
||||||
}
|
|
||||||
var columns []columnInfo
|
|
||||||
if err := tx.Raw(`
|
|
||||||
SELECT name FROM pragma_table_info('local_configurations')
|
|
||||||
WHERE name IN ('server_model')
|
|
||||||
`).Scan(&columns).Error; err != nil {
|
|
||||||
return fmt.Errorf("check local_configurations(server_model) existence: %w", err)
|
|
||||||
}
|
|
||||||
if len(columns) == 0 {
|
|
||||||
if err := tx.Exec(`
|
|
||||||
ALTER TABLE local_configurations
|
|
||||||
ADD COLUMN server_model TEXT
|
|
||||||
`).Error; err != nil {
|
|
||||||
return fmt.Errorf("add local_configurations.server_model: %w", err)
|
|
||||||
}
|
|
||||||
slog.Info("added server_model to local_configurations")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func addLocalConfigurationSupportCode(tx *gorm.DB) error {
|
|
||||||
type columnInfo struct {
|
|
||||||
Name string `gorm:"column:name"`
|
|
||||||
}
|
|
||||||
var columns []columnInfo
|
|
||||||
if err := tx.Raw(`
|
|
||||||
SELECT name FROM pragma_table_info('local_configurations')
|
|
||||||
WHERE name IN ('support_code')
|
|
||||||
`).Scan(&columns).Error; err != nil {
|
|
||||||
return fmt.Errorf("check local_configurations(support_code) existence: %w", err)
|
|
||||||
}
|
|
||||||
if len(columns) == 0 {
|
|
||||||
if err := tx.Exec(`
|
|
||||||
ALTER TABLE local_configurations
|
|
||||||
ADD COLUMN support_code TEXT
|
|
||||||
`).Error; err != nil {
|
|
||||||
return fmt.Errorf("add local_configurations.support_code: %w", err)
|
|
||||||
}
|
|
||||||
slog.Info("added support_code to local_configurations")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -57,30 +57,6 @@ 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"`
|
||||||
@@ -96,13 +72,6 @@ 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"`
|
||||||
ServerModel string `gorm:"size:100" json:"server_model,omitempty"`
|
|
||||||
SupportCode string `gorm:"size:20" json:"support_code,omitempty"`
|
|
||||||
Article string `gorm:"size:80" json:"article,omitempty"`
|
|
||||||
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"`
|
|
||||||
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"`
|
||||||
@@ -123,10 +92,7 @@ type LocalProject struct {
|
|||||||
UUID string `gorm:"uniqueIndex;not null" json:"uuid"`
|
UUID string `gorm:"uniqueIndex;not null" json:"uuid"`
|
||||||
ServerID *uint `json:"server_id,omitempty"`
|
ServerID *uint `json:"server_id,omitempty"`
|
||||||
OwnerUsername string `gorm:"not null;index" json:"owner_username"`
|
OwnerUsername string `gorm:"not null;index" json:"owner_username"`
|
||||||
Code string `gorm:"not null;index:idx_local_projects_code_variant,priority:1" json:"code"`
|
Name string `gorm:"not null" json:"name"`
|
||||||
Variant string `gorm:"default:'';index:idx_local_projects_code_variant,priority:2" json:"variant"`
|
|
||||||
Name *string `json:"name,omitempty"`
|
|
||||||
TrackerURL string `json:"tracker_url"`
|
|
||||||
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
||||||
IsSystem bool `gorm:"default:false;index" json:"is_system"`
|
IsSystem bool `gorm:"default:false;index" json:"is_system"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
@@ -159,11 +125,10 @@ 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;uniqueIndex" json:"server_id"` // ID on MariaDB server
|
ServerID uint `gorm:"not null" json:"server_id"` // ID on MariaDB server
|
||||||
Source string `gorm:"not null;default:'estimate';index:idx_local_pricelists_source_created_at,priority:1" json:"source"`
|
Version string `gorm:"uniqueIndex;not null" json:"version"`
|
||||||
Version string `gorm:"not null;index" json:"version"`
|
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
CreatedAt time.Time `gorm:"index:idx_local_pricelists_source_created_at,priority:2,sort:desc" json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
SyncedAt time.Time `json:"synced_at"`
|
SyncedAt time.Time `json:"synced_at"`
|
||||||
IsUsed bool `gorm:"default:false" json:"is_used"` // Used by any local configuration
|
IsUsed bool `gorm:"default:false" json:"is_used"` // Used by any local configuration
|
||||||
}
|
}
|
||||||
@@ -177,56 +142,27 @@ 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"`
|
||||||
LotCategory string `gorm:"column:lot_category" json:"lot_category,omitempty"`
|
|
||||||
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 {
|
||||||
return "local_pricelist_items"
|
return "local_pricelist_items"
|
||||||
}
|
}
|
||||||
|
|
||||||
// LocalComponent stores cached components for offline search (metadata only)
|
// LocalComponent stores cached components for offline search
|
||||||
// All pricing is now sourced from local_pricelist_items based on configuration pricelist selection
|
|
||||||
type LocalComponent struct {
|
type LocalComponent struct {
|
||||||
LotName string `gorm:"primaryKey" json:"lot_name"`
|
LotName string `gorm:"primaryKey" json:"lot_name"`
|
||||||
LotDescription string `json:"lot_description"`
|
LotDescription string `json:"lot_description"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
|
CurrentPrice *float64 `json:"current_price"`
|
||||||
|
SyncedAt time.Time `json:"synced_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (LocalComponent) TableName() string {
|
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"`
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
package localdb
|
|
||||||
|
|
||||||
import (
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGetLatestLocalPricelistBySource_SkipsPricelistWithoutItems(t *testing.T) {
|
|
||||||
local, err := New(filepath.Join(t.TempDir(), "latest_without_items.db"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open localdb: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = local.Close() })
|
|
||||||
|
|
||||||
base := time.Now().Add(-time.Minute)
|
|
||||||
withItems := &LocalPricelist{
|
|
||||||
ServerID: 1001,
|
|
||||||
Source: "estimate",
|
|
||||||
Version: "E-1",
|
|
||||||
Name: "with-items",
|
|
||||||
CreatedAt: base,
|
|
||||||
SyncedAt: base,
|
|
||||||
}
|
|
||||||
if err := local.SaveLocalPricelist(withItems); err != nil {
|
|
||||||
t.Fatalf("save pricelist with items: %v", err)
|
|
||||||
}
|
|
||||||
storedWithItems, err := local.GetLocalPricelistByServerID(withItems.ServerID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("load pricelist with items: %v", err)
|
|
||||||
}
|
|
||||||
if err := local.SaveLocalPricelistItems([]LocalPricelistItem{
|
|
||||||
{
|
|
||||||
PricelistID: storedWithItems.ID,
|
|
||||||
LotName: "CPU_A",
|
|
||||||
Price: 100,
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("save pricelist items: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
withoutItems := &LocalPricelist{
|
|
||||||
ServerID: 1002,
|
|
||||||
Source: "estimate",
|
|
||||||
Version: "E-2",
|
|
||||||
Name: "without-items",
|
|
||||||
CreatedAt: base.Add(2 * time.Second),
|
|
||||||
SyncedAt: base.Add(2 * time.Second),
|
|
||||||
}
|
|
||||||
if err := local.SaveLocalPricelist(withoutItems); err != nil {
|
|
||||||
t.Fatalf("save pricelist without items: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := local.GetLatestLocalPricelistBySource("estimate")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetLatestLocalPricelistBySource: %v", err)
|
|
||||||
}
|
|
||||||
if got.ServerID != withItems.ServerID {
|
|
||||||
t.Fatalf("expected server_id=%d, got %d", withItems.ServerID, got.ServerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetLatestLocalPricelistBySource_TieBreaksByID(t *testing.T) {
|
|
||||||
local, err := New(filepath.Join(t.TempDir(), "latest_tie_break.db"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open localdb: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = local.Close() })
|
|
||||||
|
|
||||||
base := time.Now().Add(-time.Minute)
|
|
||||||
first := &LocalPricelist{
|
|
||||||
ServerID: 2001,
|
|
||||||
Source: "warehouse",
|
|
||||||
Version: "S-1",
|
|
||||||
Name: "first",
|
|
||||||
CreatedAt: base,
|
|
||||||
SyncedAt: base,
|
|
||||||
}
|
|
||||||
if err := local.SaveLocalPricelist(first); err != nil {
|
|
||||||
t.Fatalf("save first pricelist: %v", err)
|
|
||||||
}
|
|
||||||
storedFirst, err := local.GetLocalPricelistByServerID(first.ServerID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("load first pricelist: %v", err)
|
|
||||||
}
|
|
||||||
if err := local.SaveLocalPricelistItems([]LocalPricelistItem{
|
|
||||||
{
|
|
||||||
PricelistID: storedFirst.ID,
|
|
||||||
LotName: "CPU_A",
|
|
||||||
Price: 101,
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("save first items: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
second := &LocalPricelist{
|
|
||||||
ServerID: 2002,
|
|
||||||
Source: "warehouse",
|
|
||||||
Version: "S-2",
|
|
||||||
Name: "second",
|
|
||||||
CreatedAt: base,
|
|
||||||
SyncedAt: base,
|
|
||||||
}
|
|
||||||
if err := local.SaveLocalPricelist(second); err != nil {
|
|
||||||
t.Fatalf("save second pricelist: %v", err)
|
|
||||||
}
|
|
||||||
storedSecond, err := local.GetLocalPricelistByServerID(second.ServerID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("load second pricelist: %v", err)
|
|
||||||
}
|
|
||||||
if err := local.SaveLocalPricelistItems([]LocalPricelistItem{
|
|
||||||
{
|
|
||||||
PricelistID: storedSecond.ID,
|
|
||||||
LotName: "CPU_A",
|
|
||||||
Price: 102,
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("save second items: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := local.GetLatestLocalPricelistBySource("warehouse")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetLatestLocalPricelistBySource: %v", err)
|
|
||||||
}
|
|
||||||
if got.ServerID != second.ServerID {
|
|
||||||
t.Fatalf("expected server_id=%d, got %d", second.ServerID, got.ServerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ package localdb
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,11 +22,6 @@ 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,
|
||||||
"server_model": localCfg.ServerModel,
|
|
||||||
"support_code": localCfg.SupportCode,
|
|
||||||
"article": localCfg.Article,
|
|
||||||
"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,
|
||||||
@@ -56,11 +50,6 @@ 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"`
|
||||||
ServerModel string `json:"server_model"`
|
|
||||||
SupportCode string `json:"support_code"`
|
|
||||||
Article string `json:"article"`
|
|
||||||
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"`
|
||||||
@@ -85,61 +74,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,
|
||||||
ServerModel: snapshot.ServerModel,
|
|
||||||
SupportCode: snapshot.SupportCode,
|
|
||||||
Article: snapshot.Article,
|
|
||||||
PricelistID: snapshot.PricelistID,
|
|
||||||
OnlyInStock: snapshot.OnlyInStock,
|
|
||||||
PriceUpdatedAt: snapshot.PriceUpdatedAt,
|
PriceUpdatedAt: snapshot.PriceUpdatedAt,
|
||||||
OriginalUserID: snapshot.OriginalUserID,
|
OriginalUserID: snapshot.OriginalUserID,
|
||||||
OriginalUsername: snapshot.OriginalUsername,
|
OriginalUsername: snapshot.OriginalUsername,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type configurationSpecPriceFingerprint struct {
|
|
||||||
Items []configurationSpecPriceFingerprintItem `json:"items"`
|
|
||||||
ServerCount int `json:"server_count"`
|
|
||||||
TotalPrice *float64 `json:"total_price,omitempty"`
|
|
||||||
CustomPrice *float64 `json:"custom_price,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type configurationSpecPriceFingerprintItem struct {
|
|
||||||
LotName string `json:"lot_name"`
|
|
||||||
Quantity int `json:"quantity"`
|
|
||||||
UnitPrice float64 `json:"unit_price"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildConfigurationSpecPriceFingerprint returns a stable JSON key based on
|
|
||||||
// spec + price fields only, used for revision deduplication.
|
|
||||||
func BuildConfigurationSpecPriceFingerprint(localCfg *LocalConfiguration) (string, error) {
|
|
||||||
items := make([]configurationSpecPriceFingerprintItem, 0, len(localCfg.Items))
|
|
||||||
for _, item := range localCfg.Items {
|
|
||||||
items = append(items, configurationSpecPriceFingerprintItem{
|
|
||||||
LotName: item.LotName,
|
|
||||||
Quantity: item.Quantity,
|
|
||||||
UnitPrice: item.UnitPrice,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
sort.Slice(items, func(i, j int) bool {
|
|
||||||
if items[i].LotName != items[j].LotName {
|
|
||||||
return items[i].LotName < items[j].LotName
|
|
||||||
}
|
|
||||||
if items[i].Quantity != items[j].Quantity {
|
|
||||||
return items[i].Quantity < items[j].Quantity
|
|
||||||
}
|
|
||||||
return items[i].UnitPrice < items[j].UnitPrice
|
|
||||||
})
|
|
||||||
|
|
||||||
payload := configurationSpecPriceFingerprint{
|
|
||||||
Items: items,
|
|
||||||
ServerCount: localCfg.ServerCount,
|
|
||||||
TotalPrice: localCfg.TotalPrice,
|
|
||||||
CustomPrice: localCfg.CustomPrice,
|
|
||||||
}
|
|
||||||
|
|
||||||
raw, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("marshal spec+price fingerprint: %w", err)
|
|
||||||
}
|
|
||||||
return string(raw), nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,238 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
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,55 +1,22 @@
|
|||||||
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) {
|
||||||
origin := strings.TrimSpace(c.GetHeader("Origin"))
|
c.Header("Access-Control-Allow-Origin", "*")
|
||||||
if origin != "" {
|
|
||||||
if isLoopbackOrigin(origin) {
|
|
||||||
c.Header("Access-Control-Allow-Origin", origin)
|
|
||||||
c.Header("Vary", "Origin")
|
|
||||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
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-Allow-Headers", "Origin, Content-Type, Accept, Authorization")
|
||||||
c.Header("Access-Control-Expose-Headers", "Content-Length, Content-Disposition")
|
c.Header("Access-Control-Expose-Headers", "Content-Length, Content-Disposition")
|
||||||
c.Header("Access-Control-Max-Age", "86400")
|
c.Header("Access-Control-Max-Age", "86400")
|
||||||
} else if c.Request.Method == http.MethodOptions {
|
|
||||||
c.AbortWithStatus(http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.Request.Method == http.MethodOptions {
|
if c.Request.Method == "OPTIONS" {
|
||||||
c.AbortWithStatus(http.StatusNoContent)
|
c.AbortWithStatus(204)
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -53,17 +53,8 @@ type Configuration struct {
|
|||||||
Notes string `gorm:"type:text" json:"notes"`
|
Notes string `gorm:"type:text" 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"`
|
||||||
ServerModel string `gorm:"size:100" json:"server_model,omitempty"`
|
|
||||||
SupportCode string `gorm:"size:20" json:"support_code,omitempty"`
|
|
||||||
Article string `gorm:"size:80" json:"article,omitempty"`
|
|
||||||
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"`
|
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
CurrentVersionNo int `gorm:"-" json:"current_version_no,omitempty"`
|
|
||||||
|
|
||||||
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,44 +37,3 @@ 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"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,41 +4,12 @@ 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"`
|
||||||
Source string `gorm:"size:20;not null;default:'estimate';uniqueIndex:idx_qt_pricelists_source_version,priority:1;index:idx_qt_pricelists_source_created_at,priority:1" json:"source"`
|
Version string `gorm:"size:20;uniqueIndex;not null" json:"version"` // Format: YYYY-MM-DD-NNN
|
||||||
Version string `gorm:"size:20;not null;uniqueIndex:idx_qt_pricelists_source_version,priority:2" json:"version"` // Format: YYYY-MM-DD-NNN
|
|
||||||
Notification string `gorm:"size:500" json:"notification"` // Notification shown in configurator
|
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"`
|
CreatedAt time.Time `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"`
|
||||||
@@ -55,7 +26,6 @@ type PricelistItem struct {
|
|||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
PricelistID uint `gorm:"not null;index:idx_pricelist_lot" json:"pricelist_id"`
|
PricelistID uint `gorm:"not null;index:idx_pricelist_lot" json:"pricelist_id"`
|
||||||
LotName string `gorm:"size:255;not null;index:idx_pricelist_lot" json:"lot_name"`
|
LotName string `gorm:"size:255;not null;index:idx_pricelist_lot" json:"lot_name"`
|
||||||
LotCategory string `gorm:"column:lot_category;size:50" json:"lot_category,omitempty"`
|
|
||||||
Price float64 `gorm:"type:decimal(12,2);not null" json:"price"`
|
Price float64 `gorm:"type:decimal(12,2);not null" json:"price"`
|
||||||
PriceMethod string `gorm:"size:20" json:"price_method"`
|
PriceMethod string `gorm:"size:20" json:"price_method"`
|
||||||
|
|
||||||
@@ -68,8 +38,6 @@ type PricelistItem struct {
|
|||||||
// 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 {
|
||||||
@@ -79,7 +47,6 @@ 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"`
|
||||||
|
|||||||
@@ -6,10 +6,7 @@ type Project 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"`
|
||||||
OwnerUsername string `gorm:"size:100;not null;index" json:"owner_username"`
|
OwnerUsername string `gorm:"size:100;not null;index" json:"owner_username"`
|
||||||
Code string `gorm:"size:100;not null;index:idx_qt_projects_code_variant,priority:1" json:"code"`
|
Name string `gorm:"size:200;not null" json:"name"`
|
||||||
Variant string `gorm:"size:100;not null;default:'';index:idx_qt_projects_code_variant,priority:2" json:"variant"`
|
|
||||||
Name *string `gorm:"size:200" json:"name,omitempty"`
|
|
||||||
TrackerURL string `gorm:"size:500" json:"tracker_url"`
|
|
||||||
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
||||||
IsSystem bool `gorm:"default:false;index" json:"is_system"`
|
IsSystem bool `gorm:"default:false;index" json:"is_system"`
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
|
|||||||
@@ -110,10 +110,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,10 @@ package repository
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
"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"
|
||||||
)
|
)
|
||||||
@@ -24,24 +21,13 @@ 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 := query.Count(&total).Error; err != nil {
|
if err := r.db.Model(&models.Pricelist{}).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 := query.Order("created_at DESC, id DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
|
if err := r.db.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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,25 +36,13 @@ func (r *PricelistRepository) ListBySource(source string, offset, limit int) ([]
|
|||||||
|
|
||||||
// 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 := query.Count(&total).Error; err != nil {
|
if err := r.db.Model(&models.Pricelist{}).Where("is_active = ?", true).Count(&total).Error; err != nil {
|
||||||
return nil, 0, fmt.Errorf("counting active pricelists: %w", err)
|
return nil, 0, fmt.Errorf("counting active pricelists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var pricelists []models.Pricelist
|
var pricelists []models.Pricelist
|
||||||
if err := query.Order("created_at DESC, id DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
|
if err := r.db.Where("is_active = ?", true).Order("created_at DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
|
||||||
return nil, 0, fmt.Errorf("listing active pricelists: %w", err)
|
return nil, 0, fmt.Errorf("listing active pricelists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,17 +64,15 @@ 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: int(usageCount),
|
UsageCount: pl.UsageCount,
|
||||||
ExpiresAt: pl.ExpiresAt,
|
ExpiresAt: pl.ExpiresAt,
|
||||||
ItemCount: itemCount,
|
ItemCount: itemCount,
|
||||||
}
|
}
|
||||||
@@ -120,22 +92,14 @@ 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("source = ? AND version = ?", source, version).First(&pricelist).Error; err != nil {
|
if err := r.db.Where("version = ?", 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
|
||||||
@@ -143,17 +107,8 @@ func (r *PricelistRepository) GetBySourceAndVersion(source, version string) (*mo
|
|||||||
|
|
||||||
// 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.
|
if err := r.db.Where("is_active = ?", true).Order("created_at DESC").First(&pricelist).Error; err != nil {
|
||||||
Where("is_active = ? AND source = ?", true, source).
|
|
||||||
Where("EXISTS (SELECT 1 FROM qt_pricelist_items WHERE qt_pricelist_items.pricelist_id = qt_pricelists.id)").
|
|
||||||
Order("created_at DESC, id 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
|
||||||
@@ -177,13 +132,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 {
|
||||||
usageCount, err := r.CountUsage(id)
|
pricelist, err := r.GetByID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if usageCount > 0 {
|
if pricelist.UsageCount > 0 {
|
||||||
return fmt.Errorf("cannot delete pricelist with usage_count > 0 (current: %d)", usageCount)
|
return fmt.Errorf("cannot delete pricelist with usage_count > 0 (current: %d)", pricelist.UsageCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete items first
|
// Delete items first
|
||||||
@@ -243,166 +198,30 @@ func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, sear
|
|||||||
if err := r.db.Where("lot_name = ?", items[i].LotName).First(&lot).Error; err == nil {
|
if err := r.db.Where("lot_name = ?", items[i].LotName).First(&lot).Error; err == nil {
|
||||||
items[i].LotDescription = lot.LotDescription
|
items[i].LotDescription = lot.LotDescription
|
||||||
}
|
}
|
||||||
items[i].Category = strings.TrimSpace(items[i].LotCategory)
|
// Parse category from lot_name (e.g., "CPU_AMD_9654" -> "CPU")
|
||||||
|
parts := strings.SplitN(items[i].LotName, "_", 2)
|
||||||
|
if len(parts) >= 1 {
|
||||||
|
items[i].Category = parts[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stock/partnumber enrichment is optional for pricelist item listing.
|
|
||||||
// Return base pricelist rows (lot_name/price/category) even when DB user lacks
|
|
||||||
// access to stock mapping tables (e.g. lot_partnumbers, stock_log).
|
|
||||||
if err := r.enrichItemsWithStock(items); err != nil {
|
|
||||||
slog.Warn("pricelist items stock enrichment skipped", "pricelist_id", pricelistID, "error", 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) {
|
||||||
return r.GenerateVersionBySource(string(models.PricelistSourceEstimate))
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateVersionBySource generates a new version string in format YYYY-MM-DD-NNN scoped by source.
|
|
||||||
func (r *PricelistRepository) GenerateVersionBySource(source string) (string, error) {
|
|
||||||
today := time.Now().Format("2006-01-02")
|
today := time.Now().Format("2006-01-02")
|
||||||
prefix := versionPrefixBySource(source)
|
|
||||||
|
|
||||||
var last models.Pricelist
|
var last models.Pricelist
|
||||||
err := r.db.Model(&models.Pricelist{}).
|
err := r.db.Model(&models.Pricelist{}).
|
||||||
Select("version").
|
Select("version").
|
||||||
Where("source = ? AND version LIKE ?", source, prefix+"-"+today+"-%").
|
Where("version LIKE ?", today+"-%").
|
||||||
Order("version DESC").
|
Order("version DESC").
|
||||||
Limit(1).
|
Limit(1).
|
||||||
Take(&last).Error
|
Take(&last).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return fmt.Sprintf("%s-%s-001", prefix, today), nil
|
return fmt.Sprintf("%s-001", today), nil
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("loading latest today's pricelist version: %w", err)
|
return "", fmt.Errorf("loading latest today's pricelist version: %w", err)
|
||||||
}
|
}
|
||||||
@@ -417,31 +236,7 @@ func (r *PricelistRepository) GenerateVersionBySource(source string) (string, er
|
|||||||
return "", fmt.Errorf("parsing pricelist sequence %q: %w", parts[len(parts)-1], err)
|
return "", fmt.Errorf("parsing pricelist sequence %q: %w", parts[len(parts)-1], err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("%s-%s-%03d", prefix, today, n+1), nil
|
return fmt.Sprintf("%s-%03d", 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
|
||||||
@@ -500,15 +295,6 @@ 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
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ import (
|
|||||||
func TestGenerateVersion_FirstOfDay(t *testing.T) {
|
func TestGenerateVersion_FirstOfDay(t *testing.T) {
|
||||||
repo := newTestPricelistRepository(t)
|
repo := newTestPricelistRepository(t)
|
||||||
|
|
||||||
version, err := repo.GenerateVersionBySource(string(models.PricelistSourceEstimate))
|
version, err := repo.GenerateVersion()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GenerateVersionBySource returned error: %v", err)
|
t.Fatalf("GenerateVersion returned error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
today := time.Now().Format("2006-01-02")
|
today := time.Now().Format("2006-01-02")
|
||||||
want := fmt.Sprintf("E-%s-001", today)
|
want := fmt.Sprintf("%s-001", today)
|
||||||
if version != want {
|
if version != want {
|
||||||
t.Fatalf("expected %s, got %s", want, version)
|
t.Fatalf("expected %s, got %s", want, version)
|
||||||
}
|
}
|
||||||
@@ -30,8 +30,8 @@ func TestGenerateVersion_UsesMaxSuffixNotCount(t *testing.T) {
|
|||||||
today := time.Now().Format("2006-01-02")
|
today := time.Now().Format("2006-01-02")
|
||||||
|
|
||||||
seed := []models.Pricelist{
|
seed := []models.Pricelist{
|
||||||
{Source: string(models.PricelistSourceEstimate), Version: fmt.Sprintf("E-%s-001", today), CreatedBy: "test", IsActive: true},
|
{Version: fmt.Sprintf("%s-001", today), CreatedBy: "test", IsActive: true},
|
||||||
{Source: string(models.PricelistSourceEstimate), Version: fmt.Sprintf("E-%s-003", today), CreatedBy: "test", IsActive: true},
|
{Version: fmt.Sprintf("%s-003", today), CreatedBy: "test", IsActive: true},
|
||||||
}
|
}
|
||||||
for _, pl := range seed {
|
for _, pl := range seed {
|
||||||
if err := repo.Create(&pl); err != nil {
|
if err := repo.Create(&pl); err != nil {
|
||||||
@@ -39,188 +39,17 @@ func TestGenerateVersion_UsesMaxSuffixNotCount(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
version, err := repo.GenerateVersionBySource(string(models.PricelistSourceEstimate))
|
version, err := repo.GenerateVersion()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GenerateVersionBySource returned error: %v", err)
|
t.Fatalf("GenerateVersion returned error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
want := fmt.Sprintf("E-%s-004", today)
|
want := fmt.Sprintf("%s-004", today)
|
||||||
if version != want {
|
if version != want {
|
||||||
t.Fatalf("expected %s, got %s", want, version)
|
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 TestGetLatestActiveBySource_SkipsPricelistsWithoutItems(t *testing.T) {
|
|
||||||
repo := newTestPricelistRepository(t)
|
|
||||||
db := repo.db
|
|
||||||
ts := time.Now().Add(-time.Minute)
|
|
||||||
source := "test-estimate-skip-empty"
|
|
||||||
|
|
||||||
emptyLatest := models.Pricelist{
|
|
||||||
Source: source,
|
|
||||||
Version: "E-empty",
|
|
||||||
CreatedBy: "test",
|
|
||||||
IsActive: true,
|
|
||||||
CreatedAt: ts.Add(2 * time.Second),
|
|
||||||
}
|
|
||||||
if err := db.Create(&emptyLatest).Error; err != nil {
|
|
||||||
t.Fatalf("create empty pricelist: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
withItems := models.Pricelist{
|
|
||||||
Source: source,
|
|
||||||
Version: "E-with-items",
|
|
||||||
CreatedBy: "test",
|
|
||||||
IsActive: true,
|
|
||||||
CreatedAt: ts,
|
|
||||||
}
|
|
||||||
if err := db.Create(&withItems).Error; err != nil {
|
|
||||||
t.Fatalf("create pricelist with items: %v", err)
|
|
||||||
}
|
|
||||||
if err := db.Create(&models.PricelistItem{
|
|
||||||
PricelistID: withItems.ID,
|
|
||||||
LotName: "CPU_A",
|
|
||||||
Price: 100,
|
|
||||||
}).Error; err != nil {
|
|
||||||
t.Fatalf("create pricelist item: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := repo.GetLatestActiveBySource(source)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetLatestActiveBySource: %v", err)
|
|
||||||
}
|
|
||||||
if got.ID != withItems.ID {
|
|
||||||
t.Fatalf("expected pricelist with items id=%d, got id=%d", withItems.ID, got.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetLatestActiveBySource_TieBreaksByID(t *testing.T) {
|
|
||||||
repo := newTestPricelistRepository(t)
|
|
||||||
db := repo.db
|
|
||||||
ts := time.Now().Add(-time.Minute)
|
|
||||||
source := "test-warehouse-tie-break"
|
|
||||||
|
|
||||||
first := models.Pricelist{
|
|
||||||
Source: source,
|
|
||||||
Version: "S-1",
|
|
||||||
CreatedBy: "test",
|
|
||||||
IsActive: true,
|
|
||||||
CreatedAt: ts,
|
|
||||||
}
|
|
||||||
if err := db.Create(&first).Error; err != nil {
|
|
||||||
t.Fatalf("create first pricelist: %v", err)
|
|
||||||
}
|
|
||||||
if err := db.Create(&models.PricelistItem{
|
|
||||||
PricelistID: first.ID,
|
|
||||||
LotName: "CPU_A",
|
|
||||||
Price: 101,
|
|
||||||
}).Error; err != nil {
|
|
||||||
t.Fatalf("create first item: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
second := models.Pricelist{
|
|
||||||
Source: source,
|
|
||||||
Version: "S-2",
|
|
||||||
CreatedBy: "test",
|
|
||||||
IsActive: true,
|
|
||||||
CreatedAt: ts,
|
|
||||||
}
|
|
||||||
if err := db.Create(&second).Error; err != nil {
|
|
||||||
t.Fatalf("create second pricelist: %v", err)
|
|
||||||
}
|
|
||||||
if err := db.Create(&models.PricelistItem{
|
|
||||||
PricelistID: second.ID,
|
|
||||||
LotName: "CPU_A",
|
|
||||||
Price: 102,
|
|
||||||
}).Error; err != nil {
|
|
||||||
t.Fatalf("create second item: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := repo.GetLatestActiveBySource(source)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetLatestActiveBySource: %v", err)
|
|
||||||
}
|
|
||||||
if got.ID != second.ID {
|
|
||||||
t.Fatalf("expected later inserted pricelist id=%d, got id=%d", second.ID, got.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTestPricelistRepository(t *testing.T) *PricelistRepository {
|
func newTestPricelistRepository(t *testing.T) *PricelistRepository {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -228,7 +57,7 @@ func newTestPricelistRepository(t *testing.T) *PricelistRepository {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open sqlite: %v", err)
|
t.Fatalf("open sqlite: %v", err)
|
||||||
}
|
}
|
||||||
if err := db.AutoMigrate(&models.Pricelist{}, &models.PricelistItem{}, &models.Lot{}, &models.LotPartnumber{}, &models.StockLog{}); err != nil {
|
if err := db.AutoMigrate(&models.Pricelist{}); err != nil {
|
||||||
t.Fatalf("migrate: %v", err)
|
t.Fatalf("migrate: %v", err)
|
||||||
}
|
}
|
||||||
return NewPricelistRepository(db)
|
return NewPricelistRepository(db)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package repository
|
|||||||
import (
|
import (
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProjectRepository struct {
|
type ProjectRepository struct {
|
||||||
@@ -22,32 +21,6 @@ func (r *ProjectRepository) Update(project *models.Project) error {
|
|||||||
return r.db.Save(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",
|
|
||||||
"code",
|
|
||||||
"variant",
|
|
||||||
"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) {
|
func (r *ProjectRepository) GetByUUID(uuid string) (*models.Project, error) {
|
||||||
var project models.Project
|
var project models.Project
|
||||||
if err := r.db.Where("uuid = ?", uuid).First(&project).Error; err != nil {
|
if err := r.db.Where("uuid = ?", uuid).First(&project).Error; err != nil {
|
||||||
|
|||||||
@@ -83,6 +83,10 @@ func (r *UnifiedRepo) getComponentsOffline(filter ComponentFilter, offset, limit
|
|||||||
search := "%" + filter.Search + "%"
|
search := "%" + filter.Search + "%"
|
||||||
query = query.Where("lot_name LIKE ? OR lot_description LIKE ? OR model LIKE ?", search, search, search)
|
query = query.Where("lot_name LIKE ? OR lot_description LIKE ? OR model LIKE ?", search, search, search)
|
||||||
}
|
}
|
||||||
|
if filter.HasPrice {
|
||||||
|
query = query.Where("current_price IS NOT NULL AND current_price > 0")
|
||||||
|
}
|
||||||
|
|
||||||
var total int64
|
var total int64
|
||||||
query.Count(&total)
|
query.Count(&total)
|
||||||
|
|
||||||
@@ -92,6 +96,8 @@ func (r *UnifiedRepo) getComponentsOffline(filter ComponentFilter, offset, limit
|
|||||||
sortDir = "DESC"
|
sortDir = "DESC"
|
||||||
}
|
}
|
||||||
switch filter.SortField {
|
switch filter.SortField {
|
||||||
|
case "current_price":
|
||||||
|
query = query.Order("current_price " + sortDir)
|
||||||
case "lot_name":
|
case "lot_name":
|
||||||
query = query.Order("lot_name " + sortDir)
|
query = query.Order("lot_name " + sortDir)
|
||||||
default:
|
default:
|
||||||
@@ -108,6 +114,7 @@ func (r *UnifiedRepo) getComponentsOffline(filter ComponentFilter, offset, limit
|
|||||||
result[i] = models.LotMetadata{
|
result[i] = models.LotMetadata{
|
||||||
LotName: comp.LotName,
|
LotName: comp.LotName,
|
||||||
Model: comp.Model,
|
Model: comp.Model,
|
||||||
|
CurrentPrice: comp.CurrentPrice,
|
||||||
Lot: &models.Lot{
|
Lot: &models.Lot{
|
||||||
LotName: comp.LotName,
|
LotName: comp.LotName,
|
||||||
LotDescription: comp.LotDescription,
|
LotDescription: comp.LotDescription,
|
||||||
@@ -133,6 +140,7 @@ func (r *UnifiedRepo) GetComponent(lotName string) (*models.LotMetadata, error)
|
|||||||
return &models.LotMetadata{
|
return &models.LotMetadata{
|
||||||
LotName: comp.LotName,
|
LotName: comp.LotName,
|
||||||
Model: comp.Model,
|
Model: comp.Model,
|
||||||
|
CurrentPrice: comp.CurrentPrice,
|
||||||
Lot: &models.Lot{
|
Lot: &models.Lot{
|
||||||
LotName: comp.LotName,
|
LotName: comp.LotName,
|
||||||
LotDescription: comp.LotDescription,
|
LotDescription: comp.LotDescription,
|
||||||
|
|||||||
199
internal/services/alerts/service.go
Normal file
199
internal/services/alerts/service.go
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -53,6 +53,7 @@ type ComponentView struct {
|
|||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
CategoryName string `json:"category_name"`
|
CategoryName string `json:"category_name"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
|
CurrentPrice *float64 `json:"current_price"`
|
||||||
PriceFreshness models.PriceFreshness `json:"price_freshness"`
|
PriceFreshness models.PriceFreshness `json:"price_freshness"`
|
||||||
PopularityScore float64 `json:"popularity_score"`
|
PopularityScore float64 `json:"popularity_score"`
|
||||||
Specs models.Specs `json:"specs,omitempty"`
|
Specs models.Specs `json:"specs,omitempty"`
|
||||||
@@ -91,6 +92,7 @@ func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage
|
|||||||
view := ComponentView{
|
view := ComponentView{
|
||||||
LotName: c.LotName,
|
LotName: c.LotName,
|
||||||
Model: c.Model,
|
Model: c.Model,
|
||||||
|
CurrentPrice: c.CurrentPrice,
|
||||||
PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3),
|
PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3),
|
||||||
PopularityScore: c.PopularityScore,
|
PopularityScore: c.PopularityScore,
|
||||||
Specs: c.Specs,
|
Specs: c.Specs,
|
||||||
@@ -132,6 +134,7 @@ func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error)
|
|||||||
view := &ComponentView{
|
view := &ComponentView{
|
||||||
LotName: c.LotName,
|
LotName: c.LotName,
|
||||||
Model: c.Model,
|
Model: c.Model,
|
||||||
|
CurrentPrice: c.CurrentPrice,
|
||||||
PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3),
|
PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3),
|
||||||
PopularityScore: c.PopularityScore,
|
PopularityScore: c.PopularityScore,
|
||||||
Specs: c.Specs,
|
Specs: c.Specs,
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ type ConfigurationService struct {
|
|||||||
configRepo *repository.ConfigurationRepository
|
configRepo *repository.ConfigurationRepository
|
||||||
projectRepo *repository.ProjectRepository
|
projectRepo *repository.ProjectRepository
|
||||||
componentRepo *repository.ComponentRepository
|
componentRepo *repository.ComponentRepository
|
||||||
pricelistRepo *repository.PricelistRepository
|
|
||||||
quoteService *QuoteService
|
quoteService *QuoteService
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,14 +31,12 @@ func NewConfigurationService(
|
|||||||
configRepo *repository.ConfigurationRepository,
|
configRepo *repository.ConfigurationRepository,
|
||||||
projectRepo *repository.ProjectRepository,
|
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,
|
projectRepo: projectRepo,
|
||||||
componentRepo: componentRepo,
|
componentRepo: componentRepo,
|
||||||
pricelistRepo: pricelistRepo,
|
|
||||||
quoteService: quoteService,
|
quoteService: quoteService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,18 +49,6 @@ type CreateConfigRequest struct {
|
|||||||
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"`
|
||||||
ServerModel string `json:"server_model,omitempty"`
|
|
||||||
SupportCode string `json:"support_code,omitempty"`
|
|
||||||
Article string `json:"article,omitempty"`
|
|
||||||
PricelistID *uint `json:"pricelist_id,omitempty"`
|
|
||||||
OnlyInStock bool `json:"only_in_stock"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ArticlePreviewRequest struct {
|
|
||||||
Items models.ConfigItems `json:"items"`
|
|
||||||
ServerModel string `json:"server_model"`
|
|
||||||
SupportCode string `json:"support_code,omitempty"`
|
|
||||||
PricelistID *uint `json:"pricelist_id,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
|
func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||||
@@ -71,10 +56,6 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
total := req.Items.Total()
|
total := req.Items.Total()
|
||||||
|
|
||||||
@@ -94,11 +75,6 @@ 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,
|
||||||
ServerModel: req.ServerModel,
|
|
||||||
SupportCode: req.SupportCode,
|
|
||||||
Article: req.Article,
|
|
||||||
PricelistID: pricelistID,
|
|
||||||
OnlyInStock: req.OnlyInStock,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.configRepo.Create(config); err != nil {
|
if err := s.configRepo.Create(config); err != nil {
|
||||||
@@ -139,10 +115,6 @@ func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *Cr
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
total := req.Items.Total()
|
total := req.Items.Total()
|
||||||
|
|
||||||
@@ -159,11 +131,6 @@ func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *Cr
|
|||||||
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.ServerModel = req.ServerModel
|
|
||||||
config.SupportCode = req.SupportCode
|
|
||||||
config.Article = req.Article
|
|
||||||
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
|
||||||
@@ -240,8 +207,6 @@ func (s *ConfigurationService) CloneToProject(configUUID string, ownerUsername s
|
|||||||
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 {
|
||||||
@@ -296,10 +261,6 @@ func (s *ConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigReques
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
||||||
@@ -314,8 +275,6 @@ func (s *ConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigReques
|
|||||||
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
|
||||||
@@ -382,8 +341,6 @@ func (s *ConfigurationService) CloneNoAuthToProject(configUUID string, newName s
|
|||||||
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 {
|
||||||
@@ -413,23 +370,6 @@ func (s *ConfigurationService) resolveProjectUUID(ownerUsername string, projectU
|
|||||||
return &project.UUID, nil
|
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)
|
||||||
@@ -437,30 +377,8 @@ 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
|
||||||
@@ -481,9 +399,6 @@ 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
|
||||||
|
|
||||||
@@ -517,32 +432,10 @@ 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
|
||||||
@@ -568,9 +461,6 @@ 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,13 +4,9 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"math"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
)
|
)
|
||||||
@@ -18,18 +14,23 @@ import (
|
|||||||
type ExportService struct {
|
type ExportService struct {
|
||||||
config config.ExportConfig
|
config config.ExportConfig
|
||||||
categoryRepo *repository.CategoryRepository
|
categoryRepo *repository.CategoryRepository
|
||||||
localDB *localdb.LocalDB
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewExportService(cfg config.ExportConfig, categoryRepo *repository.CategoryRepository, local *localdb.LocalDB) *ExportService {
|
func NewExportService(cfg config.ExportConfig, categoryRepo *repository.CategoryRepository) *ExportService {
|
||||||
return &ExportService{
|
return &ExportService{
|
||||||
config: cfg,
|
config: cfg,
|
||||||
categoryRepo: categoryRepo,
|
categoryRepo: categoryRepo,
|
||||||
localDB: local,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExportItem represents a single component in an export block.
|
type ExportData struct {
|
||||||
|
Name string
|
||||||
|
Items []ExportItem
|
||||||
|
Total float64
|
||||||
|
Notes string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type ExportItem struct {
|
type ExportItem struct {
|
||||||
LotName string
|
LotName string
|
||||||
Description string
|
Description string
|
||||||
@@ -39,45 +40,14 @@ type ExportItem struct {
|
|||||||
TotalPrice float64
|
TotalPrice float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfigExportBlock represents one configuration (server) in the export.
|
func (s *ExportService) ToCSV(data *ExportData) ([]byte, error) {
|
||||||
type ConfigExportBlock struct {
|
var buf bytes.Buffer
|
||||||
Article string
|
w := csv.NewWriter(&buf)
|
||||||
ServerCount int
|
|
||||||
UnitPrice float64 // sum of component prices for one server
|
|
||||||
Items []ExportItem
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProjectExportData holds all configuration blocks for a project-level export.
|
|
||||||
type ProjectExportData struct {
|
|
||||||
Configs []ConfigExportBlock
|
|
||||||
CreatedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToCSV writes project export data in the new structured CSV format.
|
|
||||||
//
|
|
||||||
// Format:
|
|
||||||
//
|
|
||||||
// Line;Type;p/n;Description;Qty (1 pcs.);Qty (total);Price (1 pcs.);Price (total)
|
|
||||||
// 10;;DL380-ARTICLE;;;10;10470;104 700
|
|
||||||
// ;;MB_INTEL_...;;1;;2074,5;
|
|
||||||
// ...
|
|
||||||
// (empty row)
|
|
||||||
// 20;;DL380-ARTICLE-2;;;2;10470;20 940
|
|
||||||
// ...
|
|
||||||
func (s *ExportService) ToCSV(w io.Writer, data *ProjectExportData) error {
|
|
||||||
// Write UTF-8 BOM for Excel compatibility
|
|
||||||
if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
|
|
||||||
return fmt.Errorf("failed to write BOM: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
csvWriter := csv.NewWriter(w)
|
|
||||||
csvWriter.Comma = ';'
|
|
||||||
defer csvWriter.Flush()
|
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
headers := []string{"Line", "Type", "p/n", "Description", "Qty (1 pcs.)", "Qty (total)", "Price (1 pcs.)", "Price (total)"}
|
headers := []string{"Артикул", "Описание", "Категория", "Количество", "Цена за единицу", "Сумма"}
|
||||||
if err := csvWriter.Write(headers); err != nil {
|
if err := w.Write(headers); err != nil {
|
||||||
return fmt.Errorf("failed to write header: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get category hierarchy for sorting
|
// Get category hierarchy for sorting
|
||||||
@@ -91,235 +61,87 @@ func (s *ExportService) ToCSV(w io.Writer, data *ProjectExportData) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, block := range data.Configs {
|
|
||||||
lineNo := (i + 1) * 10
|
|
||||||
|
|
||||||
serverCount := block.ServerCount
|
|
||||||
if serverCount < 1 {
|
|
||||||
serverCount = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
totalPrice := block.UnitPrice * float64(serverCount)
|
|
||||||
|
|
||||||
// Server summary row
|
|
||||||
serverRow := []string{
|
|
||||||
fmt.Sprintf("%d", lineNo), // Line
|
|
||||||
"", // Type
|
|
||||||
block.Article, // p/n
|
|
||||||
"", // Description
|
|
||||||
"", // Qty (1 pcs.)
|
|
||||||
fmt.Sprintf("%d", serverCount), // Qty (total)
|
|
||||||
formatPriceInt(block.UnitPrice), // Price (1 pcs.)
|
|
||||||
formatPriceWithSpace(totalPrice), // Price (total)
|
|
||||||
}
|
|
||||||
if err := csvWriter.Write(serverRow); err != nil {
|
|
||||||
return fmt.Errorf("failed to write server row: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort items by category display order
|
// Sort items by category display order
|
||||||
sortedItems := make([]ExportItem, len(block.Items))
|
sortedItems := make([]ExportItem, len(data.Items))
|
||||||
copy(sortedItems, block.Items)
|
copy(sortedItems, data.Items)
|
||||||
sortItemsByCategory(sortedItems, categoryOrder)
|
|
||||||
|
|
||||||
// Component rows
|
// Sort using category display order (items without category go to the end)
|
||||||
|
for i := 0; i < len(sortedItems)-1; i++ {
|
||||||
|
for j := i + 1; j < len(sortedItems); j++ {
|
||||||
|
orderI, hasI := categoryOrder[sortedItems[i].Category]
|
||||||
|
orderJ, hasJ := categoryOrder[sortedItems[j].Category]
|
||||||
|
|
||||||
|
// Items without category go to the end
|
||||||
|
if !hasI && hasJ {
|
||||||
|
sortedItems[i], sortedItems[j] = sortedItems[j], sortedItems[i]
|
||||||
|
} else if hasI && hasJ {
|
||||||
|
// Both have categories, sort by display order
|
||||||
|
if orderI > orderJ {
|
||||||
|
sortedItems[i], sortedItems[j] = sortedItems[j], sortedItems[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Items
|
||||||
for _, item := range sortedItems {
|
for _, item := range sortedItems {
|
||||||
componentRow := []string{
|
row := []string{
|
||||||
"", // Line
|
item.LotName,
|
||||||
item.Category, // Type
|
item.Description,
|
||||||
item.LotName, // p/n
|
item.Category,
|
||||||
"", // Description
|
fmt.Sprintf("%d", item.Quantity),
|
||||||
fmt.Sprintf("%d", item.Quantity), // Qty (1 pcs.)
|
fmt.Sprintf("%.2f", item.UnitPrice),
|
||||||
"", // Qty (total)
|
fmt.Sprintf("%.2f", item.TotalPrice),
|
||||||
formatPriceComma(item.UnitPrice), // Price (1 pcs.)
|
|
||||||
"", // Price (total)
|
|
||||||
}
|
}
|
||||||
if err := csvWriter.Write(componentRow); err != nil {
|
if err := w.Write(row); err != nil {
|
||||||
return fmt.Errorf("failed to write component row: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Empty separator row between blocks (skip after last)
|
|
||||||
if i < len(data.Configs)-1 {
|
|
||||||
if err := csvWriter.Write([]string{"", "", "", "", "", "", "", ""}); err != nil {
|
|
||||||
return fmt.Errorf("failed to write separator row: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
csvWriter.Flush()
|
|
||||||
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 *ProjectExportData) ([]byte, error) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if err := s.ToCSV(&buf, data); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return buf.Bytes(), nil
|
}
|
||||||
|
|
||||||
|
// Total row
|
||||||
|
if err := w.Write([]string{"", "", "", "", "ИТОГО:", fmt.Sprintf("%.2f", data.Total)}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Flush()
|
||||||
|
return buf.Bytes(), w.Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfigToExportData converts a single configuration into ProjectExportData.
|
func (s *ExportService) ConfigToExportData(config *models.Configuration, componentService *ComponentService) *ExportData {
|
||||||
func (s *ExportService) ConfigToExportData(cfg *models.Configuration) *ProjectExportData {
|
items := make([]ExportItem, len(config.Items))
|
||||||
block := s.buildExportBlock(cfg)
|
var total float64
|
||||||
return &ProjectExportData{
|
|
||||||
Configs: []ConfigExportBlock{block},
|
|
||||||
CreatedAt: cfg.CreatedAt,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProjectToExportData converts multiple configurations into ProjectExportData.
|
for i, item := range config.Items {
|
||||||
func (s *ExportService) ProjectToExportData(configs []models.Configuration) *ProjectExportData {
|
|
||||||
blocks := make([]ConfigExportBlock, 0, len(configs))
|
|
||||||
for i := range configs {
|
|
||||||
blocks = append(blocks, s.buildExportBlock(&configs[i]))
|
|
||||||
}
|
|
||||||
return &ProjectExportData{
|
|
||||||
Configs: blocks,
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ExportService) buildExportBlock(cfg *models.Configuration) ConfigExportBlock {
|
|
||||||
// Batch-fetch categories from local data (pricelist items → local_components fallback)
|
|
||||||
lotNames := make([]string, len(cfg.Items))
|
|
||||||
for i, item := range cfg.Items {
|
|
||||||
lotNames[i] = item.LotName
|
|
||||||
}
|
|
||||||
categories := s.resolveCategories(cfg.PricelistID, lotNames)
|
|
||||||
|
|
||||||
items := make([]ExportItem, len(cfg.Items))
|
|
||||||
var unitTotal float64
|
|
||||||
|
|
||||||
for i, item := range cfg.Items {
|
|
||||||
itemTotal := item.UnitPrice * float64(item.Quantity)
|
itemTotal := item.UnitPrice * float64(item.Quantity)
|
||||||
|
|
||||||
|
// Получаем информацию о компоненте для заполнения категории
|
||||||
|
componentView, err := componentService.GetByLotName(item.LotName)
|
||||||
|
if err != nil {
|
||||||
|
// Если не удалось получить информацию о компоненте, используем только основные данные
|
||||||
items[i] = ExportItem{
|
items[i] = ExportItem{
|
||||||
LotName: item.LotName,
|
LotName: item.LotName,
|
||||||
Category: categories[item.LotName],
|
|
||||||
Quantity: item.Quantity,
|
Quantity: item.Quantity,
|
||||||
UnitPrice: item.UnitPrice,
|
UnitPrice: item.UnitPrice,
|
||||||
TotalPrice: itemTotal,
|
TotalPrice: itemTotal,
|
||||||
}
|
}
|
||||||
unitTotal += itemTotal
|
} else {
|
||||||
|
items[i] = ExportItem{
|
||||||
|
LotName: item.LotName,
|
||||||
|
Description: componentView.Description,
|
||||||
|
Category: componentView.Category,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
UnitPrice: item.UnitPrice,
|
||||||
|
TotalPrice: itemTotal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total += itemTotal
|
||||||
}
|
}
|
||||||
|
|
||||||
serverCount := cfg.ServerCount
|
return &ExportData{
|
||||||
if serverCount < 1 {
|
Name: config.Name,
|
||||||
serverCount = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
return ConfigExportBlock{
|
|
||||||
Article: cfg.Article,
|
|
||||||
ServerCount: serverCount,
|
|
||||||
UnitPrice: unitTotal,
|
|
||||||
Items: items,
|
Items: items,
|
||||||
|
Total: total,
|
||||||
|
Notes: config.Notes,
|
||||||
|
CreatedAt: config.CreatedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveCategories returns lot_name → category map.
|
|
||||||
// Primary source: pricelist items (lot_category). Fallback: local_components table.
|
|
||||||
func (s *ExportService) resolveCategories(pricelistID *uint, lotNames []string) map[string]string {
|
|
||||||
if len(lotNames) == 0 || s.localDB == nil {
|
|
||||||
return map[string]string{}
|
|
||||||
}
|
|
||||||
|
|
||||||
categories := make(map[string]string, len(lotNames))
|
|
||||||
|
|
||||||
// Primary: pricelist items
|
|
||||||
if pricelistID != nil && *pricelistID > 0 {
|
|
||||||
if cats, err := s.localDB.GetLocalLotCategoriesByServerPricelistID(*pricelistID, lotNames); err == nil {
|
|
||||||
for lot, cat := range cats {
|
|
||||||
if strings.TrimSpace(cat) != "" {
|
|
||||||
categories[lot] = cat
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: local_components for any still missing
|
|
||||||
var missing []string
|
|
||||||
for _, lot := range lotNames {
|
|
||||||
if categories[lot] == "" {
|
|
||||||
missing = append(missing, lot)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(missing) > 0 {
|
|
||||||
if fallback, err := s.localDB.GetLocalComponentCategoriesByLotNames(missing); err == nil {
|
|
||||||
for lot, cat := range fallback {
|
|
||||||
if strings.TrimSpace(cat) != "" {
|
|
||||||
categories[lot] = cat
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return categories
|
|
||||||
}
|
|
||||||
|
|
||||||
// sortItemsByCategory sorts items by category display order (items without category go to the end).
|
|
||||||
func sortItemsByCategory(items []ExportItem, categoryOrder map[string]int) {
|
|
||||||
for i := 0; i < len(items)-1; i++ {
|
|
||||||
for j := i + 1; j < len(items); j++ {
|
|
||||||
orderI, hasI := categoryOrder[items[i].Category]
|
|
||||||
orderJ, hasJ := categoryOrder[items[j].Category]
|
|
||||||
|
|
||||||
if !hasI && hasJ {
|
|
||||||
items[i], items[j] = items[j], items[i]
|
|
||||||
} else if hasI && hasJ && orderI > orderJ {
|
|
||||||
items[i], items[j] = items[j], items[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatPriceComma formats a price with comma as decimal separator (e.g., "2074,5").
|
|
||||||
// Trailing zeros after the comma are trimmed, and if the value is an integer, no comma is shown.
|
|
||||||
func formatPriceComma(value float64) string {
|
|
||||||
if value == math.Trunc(value) {
|
|
||||||
return fmt.Sprintf("%.0f", value)
|
|
||||||
}
|
|
||||||
s := fmt.Sprintf("%.2f", value)
|
|
||||||
s = strings.ReplaceAll(s, ".", ",")
|
|
||||||
// Trim trailing zero: "2074,50" -> "2074,5"
|
|
||||||
s = strings.TrimRight(s, "0")
|
|
||||||
s = strings.TrimRight(s, ",")
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatPriceInt formats price as integer (rounded), no decimal.
|
|
||||||
func formatPriceInt(value float64) string {
|
|
||||||
return fmt.Sprintf("%.0f", math.Round(value))
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatPriceWithSpace formats a price as an integer with space as thousands separator (e.g., "104 700").
|
|
||||||
func formatPriceWithSpace(value float64) string {
|
|
||||||
intVal := int64(math.Round(value))
|
|
||||||
if intVal < 0 {
|
|
||||||
return "-" + formatIntWithSpace(-intVal)
|
|
||||||
}
|
|
||||||
return formatIntWithSpace(intVal)
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatIntWithSpace(n int64) string {
|
|
||||||
s := fmt.Sprintf("%d", n)
|
|
||||||
if len(s) <= 3 {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
var result strings.Builder
|
|
||||||
remainder := len(s) % 3
|
|
||||||
if remainder > 0 {
|
|
||||||
result.WriteString(s[:remainder])
|
|
||||||
}
|
|
||||||
for i := remainder; i < len(s); i += 3 {
|
|
||||||
if result.Len() > 0 {
|
|
||||||
result.WriteByte(' ')
|
|
||||||
}
|
|
||||||
result.WriteString(s[i : i+3])
|
|
||||||
}
|
|
||||||
return result.String()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,406 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/csv"
|
|
||||||
"io"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
func newTestProjectData(items []ExportItem, article string, serverCount int) *ProjectExportData {
|
|
||||||
var unitTotal float64
|
|
||||||
for _, item := range items {
|
|
||||||
unitTotal += item.UnitPrice * float64(item.Quantity)
|
|
||||||
}
|
|
||||||
if serverCount < 1 {
|
|
||||||
serverCount = 1
|
|
||||||
}
|
|
||||||
return &ProjectExportData{
|
|
||||||
Configs: []ConfigExportBlock{
|
|
||||||
{
|
|
||||||
Article: article,
|
|
||||||
ServerCount: serverCount,
|
|
||||||
UnitPrice: unitTotal,
|
|
||||||
Items: items,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToCSV_UTF8BOM(t *testing.T) {
|
|
||||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
|
||||||
|
|
||||||
data := newTestProjectData([]ExportItem{
|
|
||||||
{
|
|
||||||
LotName: "LOT-001",
|
|
||||||
Category: "CAT",
|
|
||||||
Quantity: 1,
|
|
||||||
UnitPrice: 100.0,
|
|
||||||
TotalPrice: 100.0,
|
|
||||||
},
|
|
||||||
}, "TEST-ARTICLE", 1)
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
|
||||||
actualBOM := csvBytes[:3]
|
|
||||||
if !bytes.Equal(actualBOM, expectedBOM) {
|
|
||||||
t.Errorf("UTF-8 BOM mismatch. Expected %v, got %v", expectedBOM, actualBOM)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToCSV_SemicolonDelimiter(t *testing.T) {
|
|
||||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
|
||||||
|
|
||||||
data := newTestProjectData([]ExportItem{
|
|
||||||
{
|
|
||||||
LotName: "LOT-001",
|
|
||||||
Category: "CAT",
|
|
||||||
Quantity: 2,
|
|
||||||
UnitPrice: 100.50,
|
|
||||||
TotalPrice: 201.00,
|
|
||||||
},
|
|
||||||
}, "TEST-ARTICLE", 1)
|
|
||||||
|
|
||||||
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 = ';'
|
|
||||||
|
|
||||||
// Read header
|
|
||||||
header, err := reader.Read()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to read header: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(header) != 8 {
|
|
||||||
t.Errorf("Expected 8 columns, got %d", len(header))
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedHeader := []string{"Line", "Type", "p/n", "Description", "Qty (1 pcs.)", "Qty (total)", "Price (1 pcs.)", "Price (total)"}
|
|
||||||
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 server row
|
|
||||||
serverRow, err := reader.Read()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to read server row: %v", err)
|
|
||||||
}
|
|
||||||
if serverRow[0] != "10" {
|
|
||||||
t.Errorf("Expected line number 10, got %s", serverRow[0])
|
|
||||||
}
|
|
||||||
if serverRow[2] != "TEST-ARTICLE" {
|
|
||||||
t.Errorf("Expected article TEST-ARTICLE, got %s", serverRow[2])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read component row
|
|
||||||
itemRow, err := reader.Read()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to read item row: %v", err)
|
|
||||||
}
|
|
||||||
if itemRow[2] != "LOT-001" {
|
|
||||||
t.Errorf("Lot name mismatch: expected LOT-001, got %s", itemRow[2])
|
|
||||||
}
|
|
||||||
if itemRow[4] != "2" {
|
|
||||||
t.Errorf("Quantity mismatch: expected 2, got %s", itemRow[4])
|
|
||||||
}
|
|
||||||
if itemRow[6] != "100,5" {
|
|
||||||
t.Errorf("Unit price mismatch: expected 100,5, got %s", itemRow[6])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToCSV_ServerRow(t *testing.T) {
|
|
||||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
|
||||||
|
|
||||||
data := newTestProjectData([]ExportItem{
|
|
||||||
{LotName: "LOT-001", Category: "CAT", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
|
||||||
{LotName: "LOT-002", Category: "CAT", Quantity: 2, UnitPrice: 50.0, TotalPrice: 100.0},
|
|
||||||
}, "DL380-ART", 10)
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
// Read server row
|
|
||||||
serverRow, err := reader.Read()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to read server row: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if serverRow[0] != "10" {
|
|
||||||
t.Errorf("Expected line 10, got %s", serverRow[0])
|
|
||||||
}
|
|
||||||
if serverRow[2] != "DL380-ART" {
|
|
||||||
t.Errorf("Expected article DL380-ART, got %s", serverRow[2])
|
|
||||||
}
|
|
||||||
if serverRow[5] != "10" {
|
|
||||||
t.Errorf("Expected server count 10, got %s", serverRow[5])
|
|
||||||
}
|
|
||||||
// UnitPrice = 100 + 100 = 200
|
|
||||||
if serverRow[6] != "200" {
|
|
||||||
t.Errorf("Expected unit price 200, got %s", serverRow[6])
|
|
||||||
}
|
|
||||||
// TotalPrice = 200 * 10 = 2000
|
|
||||||
if serverRow[7] != "2 000" {
|
|
||||||
t.Errorf("Expected total price '2 000', got %q", serverRow[7])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToCSV_CategorySorting(t *testing.T) {
|
|
||||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
|
||||||
|
|
||||||
data := newTestProjectData([]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},
|
|
||||||
}, "ART", 1)
|
|
||||||
|
|
||||||
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 server row
|
|
||||||
reader.Read()
|
|
||||||
reader.Read()
|
|
||||||
|
|
||||||
// Without category repo, items maintain original order
|
|
||||||
row1, _ := reader.Read()
|
|
||||||
if row1[2] != "LOT-001" {
|
|
||||||
t.Errorf("Expected LOT-001 first, got %s", row1[2])
|
|
||||||
}
|
|
||||||
|
|
||||||
row2, _ := reader.Read()
|
|
||||||
if row2[2] != "LOT-002" {
|
|
||||||
t.Errorf("Expected LOT-002 second, got %s", row2[2])
|
|
||||||
}
|
|
||||||
|
|
||||||
row3, _ := reader.Read()
|
|
||||||
if row3[2] != "LOT-003" {
|
|
||||||
t.Errorf("Expected LOT-003 third, got %s", row3[2])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToCSV_EmptyData(t *testing.T) {
|
|
||||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
|
||||||
|
|
||||||
data := &ProjectExportData{
|
|
||||||
Configs: []ConfigExportBlock{},
|
|
||||||
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 = ';'
|
|
||||||
|
|
||||||
header, err := reader.Read()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to read header: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(header) != 8 {
|
|
||||||
t.Errorf("Expected 8 columns, got %d", len(header))
|
|
||||||
}
|
|
||||||
|
|
||||||
// No more rows expected
|
|
||||||
_, err = reader.Read()
|
|
||||||
if err != io.EOF {
|
|
||||||
t.Errorf("Expected EOF after header, got: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToCSVBytes_BackwardCompat(t *testing.T) {
|
|
||||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
|
||||||
|
|
||||||
data := newTestProjectData([]ExportItem{
|
|
||||||
{LotName: "LOT-001", Category: "CAT", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
|
||||||
}, "ART", 1)
|
|
||||||
|
|
||||||
csvBytes, err := svc.ToCSVBytes(data)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ToCSVBytes failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(csvBytes) < 3 {
|
|
||||||
t.Fatalf("CSV bytes too short")
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
|
||||||
actualBOM := csvBytes[:3]
|
|
||||||
if !bytes.Equal(actualBOM, expectedBOM) {
|
|
||||||
t.Errorf("UTF-8 BOM mismatch in ToCSVBytes")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToCSV_WriterError(t *testing.T) {
|
|
||||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
|
||||||
|
|
||||||
data := newTestProjectData([]ExportItem{
|
|
||||||
{LotName: "LOT-001", Category: "CAT", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
|
||||||
}, "ART", 1)
|
|
||||||
|
|
||||||
failingWriter := &failingWriter{}
|
|
||||||
|
|
||||||
if err := svc.ToCSV(failingWriter, data); err == nil {
|
|
||||||
t.Errorf("Expected error from failing writer, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToCSV_MultipleBlocks(t *testing.T) {
|
|
||||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
|
||||||
|
|
||||||
data := &ProjectExportData{
|
|
||||||
Configs: []ConfigExportBlock{
|
|
||||||
{
|
|
||||||
Article: "ART-1",
|
|
||||||
ServerCount: 2,
|
|
||||||
UnitPrice: 500.0,
|
|
||||||
Items: []ExportItem{
|
|
||||||
{LotName: "LOT-A", Category: "CPU", Quantity: 1, UnitPrice: 500.0, TotalPrice: 500.0},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Article: "ART-2",
|
|
||||||
ServerCount: 3,
|
|
||||||
UnitPrice: 1000.0,
|
|
||||||
Items: []ExportItem{
|
|
||||||
{LotName: "LOT-B", Category: "MEM", Quantity: 2, UnitPrice: 500.0, TotalPrice: 1000.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 = ';'
|
|
||||||
reader.FieldsPerRecord = -1 // allow variable fields
|
|
||||||
|
|
||||||
// Header
|
|
||||||
reader.Read()
|
|
||||||
|
|
||||||
// Block 1: server row
|
|
||||||
srv1, _ := reader.Read()
|
|
||||||
if srv1[0] != "10" {
|
|
||||||
t.Errorf("Block 1 line: expected 10, got %s", srv1[0])
|
|
||||||
}
|
|
||||||
if srv1[7] != "1 000" {
|
|
||||||
t.Errorf("Block 1 total: expected '1 000', got %q", srv1[7])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block 1: component row
|
|
||||||
comp1, _ := reader.Read()
|
|
||||||
if comp1[2] != "LOT-A" {
|
|
||||||
t.Errorf("Block 1 component: expected LOT-A, got %s", comp1[2])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Separator row
|
|
||||||
sep, _ := reader.Read()
|
|
||||||
allEmpty := true
|
|
||||||
for _, v := range sep {
|
|
||||||
if v != "" {
|
|
||||||
allEmpty = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !allEmpty {
|
|
||||||
t.Errorf("Expected empty separator row, got %v", sep)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block 2: server row
|
|
||||||
srv2, _ := reader.Read()
|
|
||||||
if srv2[0] != "20" {
|
|
||||||
t.Errorf("Block 2 line: expected 20, got %s", srv2[0])
|
|
||||||
}
|
|
||||||
if srv2[7] != "3 000" {
|
|
||||||
t.Errorf("Block 2 total: expected '3 000', got %q", srv2[7])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFormatPriceWithSpace(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
input float64
|
|
||||||
expected string
|
|
||||||
}{
|
|
||||||
{0, "0"},
|
|
||||||
{100, "100"},
|
|
||||||
{1000, "1 000"},
|
|
||||||
{10470, "10 470"},
|
|
||||||
{104700, "104 700"},
|
|
||||||
{1000000, "1 000 000"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
result := formatPriceWithSpace(tt.input)
|
|
||||||
if result != tt.expected {
|
|
||||||
t.Errorf("formatPriceWithSpace(%v): expected %q, got %q", tt.input, tt.expected, result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFormatPriceComma(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
input float64
|
|
||||||
expected string
|
|
||||||
}{
|
|
||||||
{100.0, "100"},
|
|
||||||
{2074.5, "2074,5"},
|
|
||||||
{100.50, "100,5"},
|
|
||||||
{99.99, "99,99"},
|
|
||||||
{0, "0"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
result := formatPriceComma(tt.input)
|
|
||||||
if result != tt.expected {
|
|
||||||
t.Errorf("formatPriceComma(%v): expected %q, got %q", tt.input, tt.expected, result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// failingWriter always returns an error
|
|
||||||
type failingWriter struct{}
|
|
||||||
|
|
||||||
func (fw *failingWriter) Write(p []byte) (int, error) {
|
|
||||||
return 0, io.EOF
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/article"
|
|
||||||
"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"
|
||||||
@@ -60,22 +59,6 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.TrimSpace(req.ServerModel) != "" {
|
|
||||||
articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{
|
|
||||||
ServerModel: req.ServerModel,
|
|
||||||
SupportCode: req.SupportCode,
|
|
||||||
ServerPricelist: pricelistID,
|
|
||||||
})
|
|
||||||
if articleErr != nil {
|
|
||||||
return nil, articleErr
|
|
||||||
}
|
|
||||||
req.Article = articleResult.Article
|
|
||||||
}
|
|
||||||
|
|
||||||
total := req.Items.Total()
|
total := req.Items.Total()
|
||||||
if req.ServerCount > 1 {
|
if req.ServerCount > 1 {
|
||||||
@@ -93,11 +76,6 @@ 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,
|
||||||
ServerModel: req.ServerModel,
|
|
||||||
SupportCode: req.SupportCode,
|
|
||||||
Article: req.Article,
|
|
||||||
PricelistID: pricelistID,
|
|
||||||
OnlyInStock: req.OnlyInStock,
|
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,29 +124,10 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
|
|||||||
return nil, ErrConfigForbidden
|
return nil, ErrConfigForbidden
|
||||||
}
|
}
|
||||||
|
|
||||||
projectUUID := localCfg.ProjectUUID
|
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
|
||||||
if req.ProjectUUID != nil {
|
|
||||||
projectUUID, err = s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
|
||||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.TrimSpace(req.ServerModel) != "" {
|
|
||||||
articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{
|
|
||||||
ServerModel: req.ServerModel,
|
|
||||||
SupportCode: req.SupportCode,
|
|
||||||
ServerPricelist: pricelistID,
|
|
||||||
})
|
|
||||||
if articleErr != nil {
|
|
||||||
return nil, articleErr
|
|
||||||
}
|
|
||||||
req.Article = articleResult.Article
|
|
||||||
}
|
|
||||||
|
|
||||||
total := req.Items.Total()
|
total := req.Items.Total()
|
||||||
if req.ServerCount > 1 {
|
if req.ServerCount > 1 {
|
||||||
@@ -191,11 +150,6 @@ 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.ServerModel = req.ServerModel
|
|
||||||
localCfg.SupportCode = req.SupportCode
|
|
||||||
localCfg.Article = req.Article
|
|
||||||
localCfg.PricelistID = pricelistID
|
|
||||||
localCfg.OnlyInStock = req.OnlyInStock
|
|
||||||
localCfg.UpdatedAt = time.Now()
|
localCfg.UpdatedAt = time.Now()
|
||||||
localCfg.SyncStatus = "pending"
|
localCfg.SyncStatus = "pending"
|
||||||
|
|
||||||
@@ -207,19 +161,6 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildArticlePreview generates server article based on current items and server_model/support_code.
|
|
||||||
func (s *LocalConfigurationService) BuildArticlePreview(req *ArticlePreviewRequest) (article.BuildResult, error) {
|
|
||||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
|
||||||
if err != nil {
|
|
||||||
return article.BuildResult{}, err
|
|
||||||
}
|
|
||||||
return article.Build(s.localDB, req.Items, article.BuildOptions{
|
|
||||||
ServerModel: req.ServerModel,
|
|
||||||
SupportCode: req.SupportCode,
|
|
||||||
ServerPricelist: pricelistID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete deletes a configuration from local SQLite and queues it for sync
|
// Delete deletes a configuration from local SQLite and queues it for sync
|
||||||
func (s *LocalConfigurationService) Delete(uuid string, ownerUsername string) error {
|
func (s *LocalConfigurationService) Delete(uuid string, ownerUsername string) error {
|
||||||
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||||
@@ -313,11 +254,6 @@ func (s *LocalConfigurationService) CloneToProject(configUUID string, ownerUsern
|
|||||||
Notes: original.Notes,
|
Notes: original.Notes,
|
||||||
IsTemplate: false,
|
IsTemplate: false,
|
||||||
ServerCount: original.ServerCount,
|
ServerCount: original.ServerCount,
|
||||||
ServerModel: original.ServerModel,
|
|
||||||
SupportCode: original.SupportCode,
|
|
||||||
Article: original.Article,
|
|
||||||
PricelistID: original.PricelistID,
|
|
||||||
OnlyInStock: original.OnlyInStock,
|
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,29 +324,23 @@ 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.
|
// Update prices for all items
|
||||||
if s.isOnline() {
|
|
||||||
_ = s.syncService.SyncPricelistsIfNeeded()
|
|
||||||
}
|
|
||||||
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
|
|
||||||
|
|
||||||
// Update prices for all items from pricelist
|
|
||||||
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 {
|
||||||
if latestErr == nil && latestPricelist != nil {
|
// Get current component price from local cache
|
||||||
price, err := s.localDB.GetLocalPriceForLot(latestPricelist.ID, item.LotName)
|
component, err := s.localDB.GetLocalComponent(item.LotName)
|
||||||
if err == nil && price > 0 {
|
if err != nil || component.CurrentPrice == nil {
|
||||||
|
// Keep original item if component not found or no price available
|
||||||
|
updatedItems[i] = item
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update item with current price from local cache
|
||||||
updatedItems[i] = localdb.LocalConfigItem{
|
updatedItems[i] = localdb.LocalConfigItem{
|
||||||
LotName: item.LotName,
|
LotName: item.LotName,
|
||||||
Quantity: item.Quantity,
|
Quantity: item.Quantity,
|
||||||
UnitPrice: price,
|
UnitPrice: *component.CurrentPrice,
|
||||||
}
|
}
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep original item if price not found in pricelist
|
|
||||||
updatedItems[i] = item
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update configuration
|
// Update configuration
|
||||||
@@ -423,9 +353,6 @@ 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()
|
||||||
@@ -442,14 +369,14 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
|||||||
|
|
||||||
// GetByUUIDNoAuth returns configuration without ownership check
|
// GetByUUIDNoAuth returns configuration without ownership check
|
||||||
func (s *LocalConfigurationService) GetByUUIDNoAuth(uuid string) (*models.Configuration, error) {
|
func (s *LocalConfigurationService) GetByUUIDNoAuth(uuid string) (*models.Configuration, error) {
|
||||||
var localCfg localdb.LocalConfiguration
|
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||||
if err := s.localDB.DB().Preload("CurrentVersion").Where("uuid = ?", uuid).First(&localCfg).Error; err != nil {
|
if err != nil {
|
||||||
return nil, ErrConfigNotFound
|
return nil, ErrConfigNotFound
|
||||||
}
|
}
|
||||||
if !localCfg.IsActive {
|
if !localCfg.IsActive {
|
||||||
return nil, ErrConfigNotFound
|
return nil, ErrConfigNotFound
|
||||||
}
|
}
|
||||||
return localdb.LocalToConfiguration(&localCfg), nil
|
return localdb.LocalToConfiguration(localCfg), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateNoAuth updates configuration without ownership check
|
// UpdateNoAuth updates configuration without ownership check
|
||||||
@@ -459,42 +386,11 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
|
|||||||
return nil, ErrConfigNotFound
|
return nil, ErrConfigNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
projectUUID := localCfg.ProjectUUID
|
projectUUID, err := s.resolveProjectUUID(localCfg.OriginalUsername, req.ProjectUUID)
|
||||||
if req.ProjectUUID != nil {
|
|
||||||
requestedProjectUUID := strings.TrimSpace(*req.ProjectUUID)
|
|
||||||
currentProjectUUID := ""
|
|
||||||
if localCfg.ProjectUUID != nil {
|
|
||||||
currentProjectUUID = strings.TrimSpace(*localCfg.ProjectUUID)
|
|
||||||
}
|
|
||||||
|
|
||||||
projectUUID, err = s.resolveProjectUUID(localCfg.OriginalUsername, req.ProjectUUID)
|
|
||||||
if err != nil {
|
|
||||||
// Allow save for legacy/orphaned configs when request keeps the same project UUID.
|
|
||||||
// This can happen for imported configs whose project is not present in local cache.
|
|
||||||
if errors.Is(err, ErrProjectNotFound) && requestedProjectUUID != "" && requestedProjectUUID == currentProjectUUID {
|
|
||||||
projectUUID = localCfg.ProjectUUID
|
|
||||||
} else {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(req.ServerModel) != "" {
|
|
||||||
articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{
|
|
||||||
ServerModel: req.ServerModel,
|
|
||||||
SupportCode: req.SupportCode,
|
|
||||||
ServerPricelist: pricelistID,
|
|
||||||
})
|
|
||||||
if articleErr != nil {
|
|
||||||
return nil, articleErr
|
|
||||||
}
|
|
||||||
req.Article = articleResult.Article
|
|
||||||
}
|
|
||||||
|
|
||||||
total := req.Items.Total()
|
total := req.Items.Total()
|
||||||
if req.ServerCount > 1 {
|
if req.ServerCount > 1 {
|
||||||
total *= float64(req.ServerCount)
|
total *= float64(req.ServerCount)
|
||||||
@@ -515,11 +411,6 @@ 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.ServerModel = req.ServerModel
|
|
||||||
localCfg.SupportCode = req.SupportCode
|
|
||||||
localCfg.Article = req.Article
|
|
||||||
localCfg.PricelistID = pricelistID
|
|
||||||
localCfg.OnlyInStock = req.OnlyInStock
|
|
||||||
localCfg.UpdatedAt = time.Now()
|
localCfg.UpdatedAt = time.Now()
|
||||||
localCfg.SyncStatus = "pending"
|
localCfg.SyncStatus = "pending"
|
||||||
|
|
||||||
@@ -583,30 +474,10 @@ func (s *LocalConfigurationService) CloneNoAuth(configUUID string, newName strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *LocalConfigurationService) CloneNoAuthToProject(configUUID string, newName string, ownerUsername string, projectUUID *string) (*models.Configuration, error) {
|
func (s *LocalConfigurationService) CloneNoAuthToProject(configUUID string, newName string, ownerUsername string, projectUUID *string) (*models.Configuration, error) {
|
||||||
return s.CloneNoAuthToProjectFromVersion(configUUID, newName, ownerUsername, projectUUID, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *LocalConfigurationService) CloneNoAuthToProjectFromVersion(configUUID string, newName string, ownerUsername string, projectUUID *string, fromVersion int) (*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
|
||||||
}
|
}
|
||||||
|
|
||||||
// If fromVersion specified, use snapshot from that version
|
|
||||||
if fromVersion > 0 {
|
|
||||||
version, vErr := s.GetVersion(configUUID, fromVersion)
|
|
||||||
if vErr != nil {
|
|
||||||
return nil, vErr
|
|
||||||
}
|
|
||||||
snapshot, decErr := s.decodeConfigurationSnapshot(version.Data)
|
|
||||||
if decErr != nil {
|
|
||||||
return nil, fmt.Errorf("decode version snapshot for clone: %w", decErr)
|
|
||||||
}
|
|
||||||
snapshotCfg := localdb.LocalToConfiguration(snapshot)
|
|
||||||
original = snapshotCfg
|
|
||||||
original.UUID = configUUID // preserve original UUID for project resolution
|
|
||||||
}
|
|
||||||
|
|
||||||
resolvedProjectUUID := original.ProjectUUID
|
resolvedProjectUUID := original.ProjectUUID
|
||||||
if projectUUID != nil {
|
if projectUUID != nil {
|
||||||
resolvedProjectUUID, err = s.resolveProjectUUID(ownerUsername, projectUUID)
|
resolvedProjectUUID, err = s.resolveProjectUUID(ownerUsername, projectUUID)
|
||||||
@@ -631,8 +502,6 @@ func (s *LocalConfigurationService) CloneNoAuthToProjectFromVersion(configUUID s
|
|||||||
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(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -682,6 +551,26 @@ func (s *LocalConfigurationService) ListAll(page, perPage int) ([]models.Configu
|
|||||||
|
|
||||||
// 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, search 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
|
||||||
|
}
|
||||||
|
|
||||||
|
search = strings.ToLower(strings.TrimSpace(search))
|
||||||
|
configs := make([]models.Configuration, len(localConfigs))
|
||||||
|
configs = configs[:0]
|
||||||
|
for _, lc := range localConfigs {
|
||||||
|
if !matchesConfigStatus(lc.IsActive, status) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if search != "" && !strings.Contains(strings.ToLower(lc.Name), search) {
|
||||||
|
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
|
||||||
@@ -690,15 +579,17 @@ 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)
|
|
||||||
if err != nil {
|
start := offset
|
||||||
return nil, 0, err
|
if start > len(configs) {
|
||||||
|
start = len(configs)
|
||||||
}
|
}
|
||||||
configs := make([]models.Configuration, 0, len(localConfigs))
|
end := start + perPage
|
||||||
for _, lc := range localConfigs {
|
if end > len(configs) {
|
||||||
configs = append(configs, *localdb.LocalToConfiguration(&lc))
|
end = len(configs)
|
||||||
}
|
}
|
||||||
return configs, total, nil
|
|
||||||
|
return configs[start:end], total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListTemplates returns all template configurations
|
// ListTemplates returns all template configurations
|
||||||
@@ -749,28 +640,23 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
|
|||||||
return nil, ErrConfigNotFound
|
return nil, ErrConfigNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.isOnline() {
|
// Update prices for all items
|
||||||
_ = s.syncService.SyncPricelistsIfNeeded()
|
|
||||||
}
|
|
||||||
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
|
|
||||||
|
|
||||||
// Update prices for all items from pricelist
|
|
||||||
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 {
|
||||||
if latestErr == nil && latestPricelist != nil {
|
// Get current component price from local cache
|
||||||
price, err := s.localDB.GetLocalPriceForLot(latestPricelist.ID, item.LotName)
|
component, err := s.localDB.GetLocalComponent(item.LotName)
|
||||||
if err == nil && price > 0 {
|
if err != nil || component.CurrentPrice == nil {
|
||||||
|
// Keep original item if component not found or no price available
|
||||||
|
updatedItems[i] = item
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update item with current price from local cache
|
||||||
updatedItems[i] = localdb.LocalConfigItem{
|
updatedItems[i] = localdb.LocalConfigItem{
|
||||||
LotName: item.LotName,
|
LotName: item.LotName,
|
||||||
Quantity: item.Quantity,
|
Quantity: item.Quantity,
|
||||||
UnitPrice: price,
|
UnitPrice: *component.CurrentPrice,
|
||||||
}
|
}
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep original item if price not found in pricelist
|
|
||||||
updatedItems[i] = item
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update configuration
|
// Update configuration
|
||||||
@@ -783,9 +669,6 @@ 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()
|
||||||
@@ -800,58 +683,6 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateServerCount updates server count and recalculates total price without creating a new version.
|
|
||||||
func (s *LocalConfigurationService) UpdateServerCount(configUUID string, serverCount int) (*models.Configuration, error) {
|
|
||||||
if serverCount < 1 {
|
|
||||||
return nil, fmt.Errorf("server count must be at least 1")
|
|
||||||
}
|
|
||||||
|
|
||||||
localCfg, err := s.localDB.GetConfigurationByUUID(configUUID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, ErrConfigNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
localCfg.ServerCount = serverCount
|
|
||||||
total := localCfg.Items.Total()
|
|
||||||
if serverCount > 1 {
|
|
||||||
total *= float64(serverCount)
|
|
||||||
}
|
|
||||||
localCfg.TotalPrice = &total
|
|
||||||
localCfg.UpdatedAt = time.Now()
|
|
||||||
localCfg.SyncStatus = "pending"
|
|
||||||
|
|
||||||
var cfg *models.Configuration
|
|
||||||
err = s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := tx.Save(localCfg).Error; err != nil {
|
|
||||||
return fmt.Errorf("save local configuration: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use existing current version for the pending change
|
|
||||||
var version localdb.LocalConfigurationVersion
|
|
||||||
if localCfg.CurrentVersionID != nil && *localCfg.CurrentVersionID != "" {
|
|
||||||
if err := tx.Where("id = ?", *localCfg.CurrentVersionID).First(&version).Error; err != nil {
|
|
||||||
return fmt.Errorf("load current version: %w", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err := tx.Where("configuration_uuid = ?", localCfg.UUID).
|
|
||||||
Order("version_no DESC").First(&version).Error; err != nil {
|
|
||||||
return fmt.Errorf("load latest version: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg = localdb.LocalToConfiguration(localCfg)
|
|
||||||
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "update", &version, ""); err != nil {
|
|
||||||
return fmt.Errorf("enqueue server-count pending change: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ImportFromServer imports configurations from MariaDB to local SQLite cache.
|
// ImportFromServer imports configurations from MariaDB to local SQLite cache.
|
||||||
func (s *LocalConfigurationService) ImportFromServer() (*sync.ConfigImportResult, error) {
|
func (s *LocalConfigurationService) ImportFromServer() (*sync.ConfigImportResult, error) {
|
||||||
return s.syncService.ImportConfigurationsToLocal()
|
return s.syncService.ImportConfigurationsToLocal()
|
||||||
@@ -980,14 +811,10 @@ func (s *LocalConfigurationService) createWithVersion(localCfg *localdb.LocalCon
|
|||||||
return fmt.Errorf("set current version id: %w", err)
|
return fmt.Errorf("set current version id: %w", err)
|
||||||
}
|
}
|
||||||
localCfg.CurrentVersionID = &version.ID
|
localCfg.CurrentVersionID = &version.ID
|
||||||
localCfg.CurrentVersion = version
|
|
||||||
|
|
||||||
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
|
||||||
})
|
})
|
||||||
@@ -1007,26 +834,6 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
|
|||||||
return fmt.Errorf("lock configuration row: %w", err)
|
return fmt.Errorf("lock configuration row: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if operation == "update" {
|
|
||||||
currentVersion, err := s.loadCurrentVersionTx(tx, &locked)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("load current version before save: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legacy/orphaned rows may have empty or stale current_version_id.
|
|
||||||
// In that case we treat update as content-changing and append a fresh version.
|
|
||||||
if currentVersion != nil {
|
|
||||||
sameRevisionContent, err := s.hasSameRevisionContent(localCfg, currentVersion)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("compare revision content: %w", err)
|
|
||||||
}
|
|
||||||
if sameRevisionContent {
|
|
||||||
cfg = localdb.LocalToConfiguration(&locked)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Save(localCfg).Error; err != nil {
|
if err := tx.Save(localCfg).Error; err != nil {
|
||||||
return fmt.Errorf("save local configuration: %w", err)
|
return fmt.Errorf("save local configuration: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1042,15 +849,11 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
|
|||||||
return fmt.Errorf("update current version id: %w", err)
|
return fmt.Errorf("update current version id: %w", err)
|
||||||
}
|
}
|
||||||
localCfg.CurrentVersionID = &version.ID
|
localCfg.CurrentVersionID = &version.ID
|
||||||
localCfg.CurrentVersion = version
|
|
||||||
|
|
||||||
cfg = localdb.LocalToConfiguration(localCfg)
|
cfg = localdb.LocalToConfiguration(localCfg)
|
||||||
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
|
||||||
})
|
})
|
||||||
@@ -1061,44 +864,6 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *LocalConfigurationService) loadCurrentVersionTx(tx *gorm.DB, localCfg *localdb.LocalConfiguration) (*localdb.LocalConfigurationVersion, error) {
|
|
||||||
var version localdb.LocalConfigurationVersion
|
|
||||||
if localCfg.CurrentVersionID != nil && *localCfg.CurrentVersionID != "" {
|
|
||||||
if err := tx.Where("id = ?", *localCfg.CurrentVersionID).First(&version).Error; err == nil {
|
|
||||||
return &version, nil
|
|
||||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Where("configuration_uuid = ?", localCfg.UUID).
|
|
||||||
Order("version_no DESC").
|
|
||||||
First(&version).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &version, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *LocalConfigurationService) hasSameRevisionContent(localCfg *localdb.LocalConfiguration, currentVersion *localdb.LocalConfigurationVersion) (bool, error) {
|
|
||||||
currentSnapshotCfg, err := s.decodeConfigurationSnapshot(currentVersion.Data)
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("decode current version snapshot: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
currentFingerprint, err := localdb.BuildConfigurationSpecPriceFingerprint(currentSnapshotCfg)
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("build current snapshot fingerprint: %w", err)
|
|
||||||
}
|
|
||||||
nextFingerprint, err := localdb.BuildConfigurationSpecPriceFingerprint(localCfg)
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("build next snapshot fingerprint: %w", err)
|
|
||||||
}
|
|
||||||
return currentFingerprint == nextFingerprint, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *LocalConfigurationService) appendVersionTx(
|
func (s *LocalConfigurationService) appendVersionTx(
|
||||||
tx *gorm.DB,
|
tx *gorm.DB,
|
||||||
localCfg *localdb.LocalConfiguration,
|
localCfg *localdb.LocalConfiguration,
|
||||||
@@ -1193,8 +958,6 @@ 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"
|
||||||
@@ -1252,9 +1015,6 @@ 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
|
||||||
})
|
})
|
||||||
@@ -1278,7 +1038,6 @@ func (s *LocalConfigurationService) enqueueConfigurationPendingChangeTx(
|
|||||||
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,
|
ProjectUUID: localCfg.ProjectUUID,
|
||||||
PricelistID: localCfg.PricelistID,
|
|
||||||
Operation: operation,
|
Operation: operation,
|
||||||
CurrentVersionID: version.ID,
|
CurrentVersionID: version.ID,
|
||||||
CurrentVersionNo: version.VersionNo,
|
CurrentVersionNo: version.VersionNo,
|
||||||
@@ -1312,21 +1071,6 @@ 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 == "" {
|
||||||
@@ -1372,25 +1116,3 @@ func (s *LocalConfigurationService) resolveProjectUUID(ownerUsername string, pro
|
|||||||
|
|
||||||
return &project.UUID, nil
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -27,12 +27,8 @@ func TestSaveCreatesNewVersionAndUpdatesCurrentPointer(t *testing.T) {
|
|||||||
t.Fatalf("create config: %v", err)
|
t.Fatalf("create config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
if _, err := service.RenameNoAuth(created.UUID, "v2"); err != nil {
|
||||||
Name: "v1",
|
t.Fatalf("rename config: %v", err)
|
||||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 1000}},
|
|
||||||
ServerCount: 1,
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("update config: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
versions := loadVersions(t, local, created.UUID)
|
versions := loadVersions(t, local, created.UUID)
|
||||||
@@ -64,12 +60,8 @@ func TestRollbackCreatesNewVersionWithTargetData(t *testing.T) {
|
|||||||
t.Fatalf("create config: %v", err)
|
t.Fatalf("create config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
if _, err := service.RenameNoAuth(created.UUID, "changed"); err != nil {
|
||||||
Name: "base",
|
t.Fatalf("rename config: %v", err)
|
||||||
Items: models.ConfigItems{{LotName: "RAM_A", Quantity: 3, UnitPrice: 100}},
|
|
||||||
ServerCount: 1,
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("update config: %v", err)
|
|
||||||
}
|
}
|
||||||
if _, err := service.RollbackToVersionWithNote(created.UUID, 1, "tester", "test rollback"); err != nil {
|
if _, err := service.RollbackToVersionWithNote(created.UUID, 1, "tester", "test rollback"); err != nil {
|
||||||
t.Fatalf("rollback to v1: %v", err)
|
t.Fatalf("rollback to v1: %v", err)
|
||||||
@@ -87,56 +79,6 @@ func TestRollbackCreatesNewVersionWithTargetData(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateNoAuthSkipsRevisionWhenSpecAndPriceUnchanged(t *testing.T) {
|
|
||||||
service, local := newLocalConfigServiceForTest(t)
|
|
||||||
|
|
||||||
created, err := service.Create("tester", &CreateConfigRequest{
|
|
||||||
Name: "dedupe",
|
|
||||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}},
|
|
||||||
ServerCount: 1,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
|
||||||
Name: "dedupe",
|
|
||||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 1000}},
|
|
||||||
ServerCount: 1,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("first update config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
|
||||||
Name: "dedupe",
|
|
||||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 1000}},
|
|
||||||
ServerCount: 1,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("second update config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
versions := loadVersions(t, local, created.UUID)
|
|
||||||
if len(versions) != 2 {
|
|
||||||
t.Fatalf("expected 2 versions (create + first update), got %d", len(versions))
|
|
||||||
}
|
|
||||||
if versions[1].VersionNo != 2 {
|
|
||||||
t.Fatalf("expected latest version_no=2, got %d", versions[1].VersionNo)
|
|
||||||
}
|
|
||||||
|
|
||||||
var pendingCount int64
|
|
||||||
if err := local.DB().
|
|
||||||
Table("pending_changes").
|
|
||||||
Where("entity_type = ? AND entity_uuid = ?", "configuration", created.UUID).
|
|
||||||
Count(&pendingCount).Error; err != nil {
|
|
||||||
t.Fatalf("count pending changes: %v", err)
|
|
||||||
}
|
|
||||||
if pendingCount != 2 {
|
|
||||||
t.Fatalf("expected 2 pending changes (create + first update), got %d", pendingCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAppendOnlyInvariantOldRowsUnchanged(t *testing.T) {
|
func TestAppendOnlyInvariantOldRowsUnchanged(t *testing.T) {
|
||||||
service, local := newLocalConfigServiceForTest(t)
|
service, local := newLocalConfigServiceForTest(t)
|
||||||
|
|
||||||
@@ -155,12 +97,8 @@ func TestAppendOnlyInvariantOldRowsUnchanged(t *testing.T) {
|
|||||||
}
|
}
|
||||||
v1Before := versionsBefore[0]
|
v1Before := versionsBefore[0]
|
||||||
|
|
||||||
if _, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
if _, err := service.RenameNoAuth(created.UUID, "after-rename"); err != nil {
|
||||||
Name: "initial",
|
t.Fatalf("rename config: %v", err)
|
||||||
Items: models.ConfigItems{{LotName: "SSD_A", Quantity: 2, UnitPrice: 300}},
|
|
||||||
ServerCount: 1,
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("update config: %v", err)
|
|
||||||
}
|
}
|
||||||
if _, err := service.RollbackToVersion(created.UUID, 1, "tester"); err != nil {
|
if _, err := service.RollbackToVersion(created.UUID, 1, "tester"); err != nil {
|
||||||
t.Fatalf("rollback: %v", err)
|
t.Fatalf("rollback: %v", err)
|
||||||
@@ -206,7 +144,7 @@ func TestConcurrentSaveNoDuplicateVersionNumbers(t *testing.T) {
|
|||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
<-start
|
<-start
|
||||||
if err := updateWithRetry(service, created.UUID, i+2); err != nil {
|
if err := renameWithRetry(service, created.UUID, fmt.Sprintf("name-%d", i)); err != nil {
|
||||||
errCh <- err
|
errCh <- err
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -247,146 +185,6 @@ 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",
|
|
||||||
Code: "TEST-KEEP",
|
|
||||||
Name: ptrString("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 TestUpdateNoAuthAllowsOrphanProjectWhenUUIDUnchanged(t *testing.T) {
|
|
||||||
service, local := newLocalConfigServiceForTest(t)
|
|
||||||
|
|
||||||
project := &localdb.LocalProject{
|
|
||||||
UUID: "project-orphan",
|
|
||||||
OwnerUsername: "tester",
|
|
||||||
Code: "TEST-ORPHAN",
|
|
||||||
Name: ptrString("Orphan 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simulate missing project in local cache while config still references its UUID.
|
|
||||||
if err := local.DB().Where("uuid = ?", project.UUID).Delete(&localdb.LocalProject{}).Error; err != nil {
|
|
||||||
t.Fatalf("delete project: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
|
||||||
Name: "cfg-updated",
|
|
||||||
ProjectUUID: &project.UUID,
|
|
||||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 100}},
|
|
||||||
ServerCount: 1,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("update config with orphan 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 TestUpdateNoAuthRecoversWhenCurrentVersionMissing(t *testing.T) {
|
|
||||||
service, local := newLocalConfigServiceForTest(t)
|
|
||||||
|
|
||||||
created, err := service.Create("tester", &CreateConfigRequest{
|
|
||||||
Name: "cfg",
|
|
||||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 100}},
|
|
||||||
ServerCount: 1,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simulate corrupted/legacy versioning state:
|
|
||||||
// local configuration exists, but all version rows are gone and pointer is stale.
|
|
||||||
if err := local.DB().Where("configuration_uuid = ?", created.UUID).
|
|
||||||
Delete(&localdb.LocalConfigurationVersion{}).Error; err != nil {
|
|
||||||
t.Fatalf("delete versions: %v", err)
|
|
||||||
}
|
|
||||||
staleID := "missing-version-id"
|
|
||||||
if err := local.DB().Model(&localdb.LocalConfiguration{}).
|
|
||||||
Where("uuid = ?", created.UUID).
|
|
||||||
Update("current_version_id", staleID).Error; err != nil {
|
|
||||||
t.Fatalf("set stale current_version_id: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 with missing current version: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if updated.Name != "cfg-updated" {
|
|
||||||
t.Fatalf("expected updated name, got %q", updated.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
versions := loadVersions(t, local, created.UUID)
|
|
||||||
if len(versions) != 1 {
|
|
||||||
t.Fatalf("expected 1 recreated version, got %d", len(versions))
|
|
||||||
}
|
|
||||||
if versions[0].VersionNo != 1 {
|
|
||||||
t.Fatalf("expected recreated version_no=1, got %d", versions[0].VersionNo)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ptrString(value string) *string {
|
|
||||||
return &value
|
|
||||||
}
|
|
||||||
|
|
||||||
func newLocalConfigServiceForTest(t *testing.T) (*LocalConfigurationService, *localdb.LocalDB) {
|
func newLocalConfigServiceForTest(t *testing.T) (*LocalConfigurationService, *localdb.LocalDB) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -419,14 +217,10 @@ func loadVersions(t *testing.T, local *localdb.LocalDB, configurationUUID string
|
|||||||
return versions
|
return versions
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateWithRetry(service *LocalConfigurationService, uuid string, quantity int) error {
|
func renameWithRetry(service *LocalConfigurationService, uuid string, name string) error {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for i := 0; i < 6; i++ {
|
for i := 0; i < 6; i++ {
|
||||||
_, err := service.UpdateNoAuth(uuid, &CreateConfigRequest{
|
_, err := service.RenameNoAuth(uuid, name)
|
||||||
Name: "base",
|
|
||||||
Items: models.ConfigItems{{LotName: "NIC_A", Quantity: quantity, UnitPrice: 150}},
|
|
||||||
ServerCount: 1,
|
|
||||||
})
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -437,7 +231,7 @@ func updateWithRetry(service *LocalConfigurationService, uuid string, quantity i
|
|||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return fmt.Errorf("update retries exhausted: %w", lastErr)
|
return fmt.Errorf("rename retries exhausted: %w", lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRollbackVersionSnapshotJSONMatchesV1(t *testing.T) {
|
func TestRollbackVersionSnapshotJSONMatchesV1(t *testing.T) {
|
||||||
@@ -451,12 +245,8 @@ func TestRollbackVersionSnapshotJSONMatchesV1(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create config: %v", err)
|
t.Fatalf("create config: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
if _, err := service.RenameNoAuth(created.UUID, "second"); err != nil {
|
||||||
Name: "initial",
|
t.Fatalf("rename: %v", err)
|
||||||
Items: models.ConfigItems{{LotName: "GPU_A", Quantity: 2, UnitPrice: 2000}},
|
|
||||||
ServerCount: 1,
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("update: %v", err)
|
|
||||||
}
|
}
|
||||||
if _, err := service.RollbackToVersion(created.UUID, 1, "tester"); err != nil {
|
if _, err := service.RollbackToVersion(created.UUID, 1, "tester"); err != nil {
|
||||||
t.Fatalf("rollback: %v", err)
|
t.Fatalf("rollback: %v", err)
|
||||||
|
|||||||
210
internal/services/pricelist/service.go
Normal file
210
internal/services/pricelist/service.go
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
package pricelist
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"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")
|
||||||
|
}
|
||||||
|
|
||||||
|
expiresAt := time.Now().AddDate(1, 0, 0) // +1 year
|
||||||
|
const maxCreateAttempts = 5
|
||||||
|
var pricelist *models.Pricelist
|
||||||
|
for attempt := 1; attempt <= maxCreateAttempts; attempt++ {
|
||||||
|
version, err := s.repo.GenerateVersion()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("generating version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pricelist = &models.Pricelist{
|
||||||
|
Version: version,
|
||||||
|
CreatedBy: createdBy,
|
||||||
|
IsActive: true,
|
||||||
|
ExpiresAt: &expiresAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.Create(pricelist); err != nil {
|
||||||
|
if isVersionConflictError(err) && attempt < maxCreateAttempts {
|
||||||
|
slog.Warn("pricelist version conflict, retrying",
|
||||||
|
"attempt", attempt,
|
||||||
|
"version", version,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
time.Sleep(time.Duration(attempt*25) * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("creating pricelist: %w", err)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
func isVersionConflictError(err error) bool {
|
||||||
|
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
msg := strings.ToLower(err.Error())
|
||||||
|
return strings.Contains(msg, "duplicate entry") && strings.Contains(msg, "idx_qt_pricelists_version")
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns pricelists with pagination
|
||||||
|
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
|
||||||
|
}
|
||||||
121
internal/services/pricing/calculator.go
Normal file
121
internal/services/pricing/calculator.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
205
internal/services/pricing/service.go
Normal file
205
internal/services/pricing/service.go
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -18,8 +17,6 @@ import (
|
|||||||
var (
|
var (
|
||||||
ErrProjectNotFound = errors.New("project not found")
|
ErrProjectNotFound = errors.New("project not found")
|
||||||
ErrProjectForbidden = errors.New("access to project forbidden")
|
ErrProjectForbidden = errors.New("access to project forbidden")
|
||||||
ErrProjectCodeExists = errors.New("project code and variant already exist")
|
|
||||||
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProjectService struct {
|
type ProjectService struct {
|
||||||
@@ -31,17 +28,11 @@ func NewProjectService(localDB *localdb.LocalDB) *ProjectService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CreateProjectRequest struct {
|
type CreateProjectRequest struct {
|
||||||
Code string `json:"code"`
|
Name string `json:"name"`
|
||||||
Variant string `json:"variant,omitempty"`
|
|
||||||
Name *string `json:"name,omitempty"`
|
|
||||||
TrackerURL string `json:"tracker_url"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateProjectRequest struct {
|
type UpdateProjectRequest struct {
|
||||||
Code *string `json:"code,omitempty"`
|
Name string `json:"name"`
|
||||||
Variant *string `json:"variant,omitempty"`
|
|
||||||
Name *string `json:"name,omitempty"`
|
|
||||||
TrackerURL *string `json:"tracker_url,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProjectConfigurationsResult struct {
|
type ProjectConfigurationsResult struct {
|
||||||
@@ -51,30 +42,16 @@ type ProjectConfigurationsResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest) (*models.Project, error) {
|
func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest) (*models.Project, error) {
|
||||||
var namePtr *string
|
name := strings.TrimSpace(req.Name)
|
||||||
if req.Name != nil {
|
if name == "" {
|
||||||
name := strings.TrimSpace(*req.Name)
|
return nil, fmt.Errorf("project name is required")
|
||||||
if name != "" {
|
|
||||||
namePtr = &name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
code := strings.TrimSpace(req.Code)
|
|
||||||
if code == "" {
|
|
||||||
return nil, fmt.Errorf("project code is required")
|
|
||||||
}
|
|
||||||
variant := strings.TrimSpace(req.Variant)
|
|
||||||
if err := s.ensureUniqueProjectCodeVariant("", code, variant); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
localProject := &localdb.LocalProject{
|
localProject := &localdb.LocalProject{
|
||||||
UUID: uuid.NewString(),
|
UUID: uuid.NewString(),
|
||||||
OwnerUsername: ownerUsername,
|
OwnerUsername: ownerUsername,
|
||||||
Code: code,
|
Name: name,
|
||||||
Variant: variant,
|
|
||||||
Name: namePtr,
|
|
||||||
TrackerURL: normalizeProjectTrackerURL(code, req.TrackerURL),
|
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
IsSystem: false,
|
IsSystem: false,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
@@ -95,34 +72,16 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, ErrProjectNotFound
|
return nil, ErrProjectNotFound
|
||||||
}
|
}
|
||||||
|
if localProject.OwnerUsername != ownerUsername {
|
||||||
if req.Code != nil {
|
return nil, ErrProjectForbidden
|
||||||
code := strings.TrimSpace(*req.Code)
|
|
||||||
if code == "" {
|
|
||||||
return nil, fmt.Errorf("project code is required")
|
|
||||||
}
|
|
||||||
localProject.Code = code
|
|
||||||
}
|
|
||||||
if req.Variant != nil {
|
|
||||||
localProject.Variant = strings.TrimSpace(*req.Variant)
|
|
||||||
}
|
|
||||||
if err := s.ensureUniqueProjectCodeVariant(projectUUID, localProject.Code, localProject.Variant); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Name != nil {
|
name := strings.TrimSpace(req.Name)
|
||||||
name := strings.TrimSpace(*req.Name)
|
|
||||||
if name == "" {
|
if name == "" {
|
||||||
localProject.Name = nil
|
return nil, fmt.Errorf("project name is required")
|
||||||
} else {
|
|
||||||
localProject.Name = &name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if req.TrackerURL != nil {
|
|
||||||
localProject.TrackerURL = normalizeProjectTrackerURL(localProject.Code, *req.TrackerURL)
|
|
||||||
} else if strings.TrimSpace(localProject.TrackerURL) == "" {
|
|
||||||
localProject.TrackerURL = normalizeProjectTrackerURL(localProject.Code, "")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
localProject.Name = name
|
||||||
localProject.UpdatedAt = time.Now()
|
localProject.UpdatedAt = time.Now()
|
||||||
localProject.SyncStatus = "pending"
|
localProject.SyncStatus = "pending"
|
||||||
if err := s.localDB.SaveProject(localProject); err != nil {
|
if err := s.localDB.SaveProject(localProject); err != nil {
|
||||||
@@ -134,38 +93,6 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
|
|||||||
return localdb.LocalToProject(localProject), nil
|
return localdb.LocalToProject(localProject), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ProjectService) ensureUniqueProjectCodeVariant(excludeUUID, code, variant string) error {
|
|
||||||
normalizedCode := normalizeProjectCode(code)
|
|
||||||
normalizedVariant := normalizeProjectVariant(variant)
|
|
||||||
if normalizedCode == "" {
|
|
||||||
return fmt.Errorf("project code is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
projects, err := s.localDB.GetAllProjects(true)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for i := range projects {
|
|
||||||
project := projects[i]
|
|
||||||
if excludeUUID != "" && project.UUID == excludeUUID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if normalizeProjectCode(project.Code) == normalizedCode &&
|
|
||||||
normalizeProjectVariant(project.Variant) == normalizedVariant {
|
|
||||||
return ErrProjectCodeExists
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeProjectCode(code string) string {
|
|
||||||
return strings.ToLower(strings.TrimSpace(code))
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeProjectVariant(variant string) string {
|
|
||||||
return strings.ToLower(strings.TrimSpace(variant))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProjectService) Archive(projectUUID, ownerUsername string) error {
|
func (s *ProjectService) Archive(projectUUID, ownerUsername string) error {
|
||||||
return s.setProjectActive(projectUUID, ownerUsername, false)
|
return s.setProjectActive(projectUUID, ownerUsername, false)
|
||||||
}
|
}
|
||||||
@@ -174,23 +101,15 @@ func (s *ProjectService) Reactivate(projectUUID, ownerUsername string) error {
|
|||||||
return s.setProjectActive(projectUUID, ownerUsername, true)
|
return s.setProjectActive(projectUUID, ownerUsername, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ProjectService) DeleteVariant(projectUUID, ownerUsername string) error {
|
|
||||||
localProject, err := s.localDB.GetProjectByUUID(projectUUID)
|
|
||||||
if err != nil {
|
|
||||||
return ErrProjectNotFound
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(localProject.Variant) == "" {
|
|
||||||
return ErrCannotDeleteMainVariant
|
|
||||||
}
|
|
||||||
return s.setProjectActive(projectUUID, ownerUsername, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProjectService) setProjectActive(projectUUID, ownerUsername string, isActive bool) error {
|
func (s *ProjectService) setProjectActive(projectUUID, ownerUsername string, isActive bool) error {
|
||||||
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
||||||
var project localdb.LocalProject
|
var project localdb.LocalProject
|
||||||
if err := tx.Where("uuid = ?", projectUUID).First(&project).Error; err != nil {
|
if err := tx.Where("uuid = ?", projectUUID).First(&project).Error; err != nil {
|
||||||
return ErrProjectNotFound
|
return ErrProjectNotFound
|
||||||
}
|
}
|
||||||
|
if project.OwnerUsername != ownerUsername {
|
||||||
|
return ErrProjectForbidden
|
||||||
|
}
|
||||||
if project.IsActive == isActive {
|
if project.IsActive == isActive {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -275,8 +194,8 @@ func (s *ProjectService) ListConfigurations(projectUUID, ownerUsername, status s
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var localConfigs []localdb.LocalConfiguration
|
localConfigs, err := s.localDB.GetConfigurations()
|
||||||
if err := s.localDB.DB().Preload("CurrentVersion").Order("created_at DESC").Find(&localConfigs).Error; err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,20 +260,6 @@ func (s *ProjectService) ResolveProjectUUID(ownerUsername string, projectUUID *s
|
|||||||
return &resolved, nil
|
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 {
|
func (s *ProjectService) enqueueProjectPendingChange(project *localdb.LocalProject, operation string) error {
|
||||||
return s.enqueueProjectPendingChangeTx(s.localDB.DB(), project, operation)
|
return s.enqueueProjectPendingChangeTx(s.localDB.DB(), project, operation)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,10 @@ 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 (
|
||||||
@@ -20,41 +17,21 @@ var (
|
|||||||
type QuoteService struct {
|
type QuoteService struct {
|
||||||
componentRepo *repository.ComponentRepository
|
componentRepo *repository.ComponentRepository
|
||||||
statsRepo *repository.StatsRepository
|
statsRepo *repository.StatsRepository
|
||||||
pricelistRepo *repository.PricelistRepository
|
pricingService *pricing.Service
|
||||||
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,
|
||||||
pricelistRepo *repository.PricelistRepository,
|
pricingService *pricing.Service,
|
||||||
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"`
|
||||||
@@ -78,105 +55,14 @@ type QuoteRequest struct {
|
|||||||
LotName string `json:"lot_name"`
|
LotName string `json:"lot_name"`
|
||||||
Quantity int `json:"quantity"`
|
Quantity int `json:"quantity"`
|
||||||
} `json:"items"`
|
} `json:"items"`
|
||||||
PricelistID *uint `json:"pricelist_id,omitempty"` // Optional: use specific pricelist for pricing
|
|
||||||
}
|
|
||||||
|
|
||||||
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),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine which pricelist to use for pricing
|
|
||||||
pricelistID := req.PricelistID
|
|
||||||
if pricelistID == nil || *pricelistID == 0 {
|
|
||||||
// By default, use latest estimate pricelist
|
|
||||||
latestPricelist, err := s.localDB.GetLatestLocalPricelistBySource("estimate")
|
|
||||||
if err == nil && latestPricelist != nil {
|
|
||||||
pricelistID = &latestPricelist.ServerID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get price from pricelist_items
|
|
||||||
if pricelistID != nil {
|
|
||||||
price, found := s.lookupPriceByPricelistID(*pricelistID, reqItem.LotName)
|
|
||||||
if found && price > 0 {
|
|
||||||
item.UnitPrice = price
|
|
||||||
item.TotalPrice = price * float64(reqItem.Quantity)
|
|
||||||
item.HasPrice = true
|
|
||||||
total += item.TotalPrice
|
|
||||||
} else {
|
|
||||||
result.Warnings = append(result.Warnings, "No price available for: "+reqItem.LotName)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result.Warnings = append(result.Warnings, "No pricelist 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("quote calculation not available")
|
return nil, errors.New("offline mode: quote calculation not available")
|
||||||
}
|
}
|
||||||
|
|
||||||
result := &QuoteValidationResult{
|
result := &QuoteValidationResult{
|
||||||
@@ -244,258 +130,6 @@ 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 {
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,410 +0,0 @@
|
|||||||
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,8 +5,6 @@ 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/appmeta"
|
||||||
@@ -51,13 +49,6 @@ 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"`
|
||||||
@@ -65,13 +56,6 @@ 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 {
|
||||||
@@ -79,7 +63,6 @@ type ConfigurationChangePayload struct {
|
|||||||
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"`
|
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"`
|
||||||
@@ -161,79 +144,6 @@ 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.Code = project.Code
|
|
||||||
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()
|
||||||
@@ -293,29 +203,22 @@ func (s *Service) NeedSync() (bool, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
||||||
sources := []models.PricelistSource{
|
latestServer, err := pricelistRepo.GetLatestActive()
|
||||||
models.PricelistSourceEstimate,
|
|
||||||
models.PricelistSourceWarehouse,
|
|
||||||
models.PricelistSourceCompetitor,
|
|
||||||
}
|
|
||||||
for _, source := range sources {
|
|
||||||
latestServer, err := pricelistRepo.GetLatestActiveBySource(string(source))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// No active pricelist for this source yet.
|
// If no pricelists on server, no need to sync
|
||||||
continue
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
latestLocal, err := s.localDB.GetLatestLocalPricelistBySource(string(source))
|
latestLocal, err := s.localDB.GetLatestLocalPricelist()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// No local pricelist for an existing source on server.
|
// No local pricelists, need to sync
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// If server has newer pricelist for this source, need sync.
|
// If server has newer pricelist, need sync
|
||||||
if latestServer.ID != latestLocal.ServerID {
|
if latestServer.ID != latestLocal.ServerID {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
@@ -323,9 +226,6 @@ 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.getDB()
|
mariaDB, err := s.getDB()
|
||||||
@@ -341,24 +241,18 @@ 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 latestServerID uint
|
||||||
for _, pl := range serverPricelists {
|
for _, pl := range serverPricelists {
|
||||||
// Check if pricelist already exists locally
|
// Check if pricelist already exists locally
|
||||||
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
|
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
|
||||||
if existing != nil {
|
if existing != nil {
|
||||||
// Backfill items for legacy/partial local caches where only pricelist metadata exists.
|
// Already synced, track latest by server ID
|
||||||
if s.localDB.CountLocalPricelistItems(existing.ID) == 0 {
|
if pl.ID > latestServerID {
|
||||||
itemCount, err := s.SyncPricelistItems(existing.ID)
|
latestServerID = pl.ID
|
||||||
if err != nil {
|
latestLocalID = existing.ID
|
||||||
slog.Warn("failed to sync missing pricelist items for existing local pricelist", "version", pl.Version, "error", err)
|
|
||||||
} else {
|
|
||||||
slog.Info("synced missing pricelist items for existing local pricelist", "version", pl.Version, "items", itemCount)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -366,7 +260,6 @@ 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,
|
||||||
@@ -388,270 +281,30 @@ 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 {
|
||||||
|
latestServerID = pl.ID
|
||||||
|
latestLocalID = localPL.ID
|
||||||
|
}
|
||||||
synced++
|
synced++
|
||||||
}
|
}
|
||||||
|
|
||||||
removed, err := s.localDB.DeleteUnusedLocalPricelistsMissingOnServer(serverPricelistIDs)
|
// Update component prices from latest pricelist
|
||||||
|
if latestLocalID > 0 {
|
||||||
|
updated, err := s.localDB.UpdateComponentPricesFromPricelist(latestLocalID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("failed to cleanup stale local pricelists", "error", err)
|
slog.Warn("failed to update component prices from pricelist", "error", err)
|
||||||
} else if removed > 0 {
|
} else {
|
||||||
slog.Info("deleted stale local pricelists", "deleted", removed)
|
slog.Info("updated component prices from latest pricelist", "updated", updated)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backfill lot_category for used pricelists (older local caches may miss the column values).
|
|
||||||
s.backfillUsedPricelistItemCategories(pricelistRepo, serverPricelistIDs)
|
|
||||||
|
|
||||||
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) backfillUsedPricelistItemCategories(pricelistRepo *repository.PricelistRepository, activeServerPricelistIDs []uint) {
|
|
||||||
if s.localDB == nil || pricelistRepo == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
activeSet := make(map[uint]struct{}, len(activeServerPricelistIDs))
|
|
||||||
for _, id := range activeServerPricelistIDs {
|
|
||||||
activeSet[id] = struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
type row struct {
|
|
||||||
ID uint `gorm:"column:id"`
|
|
||||||
}
|
|
||||||
var usedRows []row
|
|
||||||
if err := s.localDB.DB().Raw(`
|
|
||||||
SELECT DISTINCT pricelist_id AS id
|
|
||||||
FROM local_configurations
|
|
||||||
WHERE is_active = 1 AND pricelist_id IS NOT NULL
|
|
||||||
UNION
|
|
||||||
SELECT DISTINCT warehouse_pricelist_id AS id
|
|
||||||
FROM local_configurations
|
|
||||||
WHERE is_active = 1 AND warehouse_pricelist_id IS NOT NULL
|
|
||||||
UNION
|
|
||||||
SELECT DISTINCT competitor_pricelist_id AS id
|
|
||||||
FROM local_configurations
|
|
||||||
WHERE is_active = 1 AND competitor_pricelist_id IS NOT NULL
|
|
||||||
`).Scan(&usedRows).Error; err != nil {
|
|
||||||
slog.Warn("pricelist category backfill: failed to list used pricelists", "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, r := range usedRows {
|
|
||||||
serverID := r.ID
|
|
||||||
if serverID == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, ok := activeSet[serverID]; !ok {
|
|
||||||
// Not present on server (or not active) - cannot backfill from remote.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
localPL, err := s.localDB.GetLocalPricelistByServerID(serverID)
|
|
||||||
if err != nil || localPL == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.localDB.CountLocalPricelistItems(localPL.ID) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
missing, err := s.localDB.CountLocalPricelistItemsWithEmptyCategory(localPL.ID)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("pricelist category backfill: failed to check local items", "server_id", serverID, "error", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if missing == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
serverItems, _, err := pricelistRepo.GetItems(serverID, 0, 10000, "")
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("pricelist category backfill: failed to load server items", "server_id", serverID, "error", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
localItems := make([]localdb.LocalPricelistItem, len(serverItems))
|
|
||||||
for i := range serverItems {
|
|
||||||
localItems[i] = *localdb.PricelistItemToLocal(&serverItems[i], localPL.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.localDB.ReplaceLocalPricelistItems(localPL.ID, localItems); err != nil {
|
|
||||||
slog.Warn("pricelist category backfill: failed to replace local items", "server_id", serverID, "error", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
slog.Info("pricelist category backfill: refreshed local items", "server_id", serverID, "items", len(localItems))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
||||||
@@ -685,7 +338,11 @@ 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 {
|
||||||
localItems[i] = *localdb.PricelistItemToLocal(&item, localPricelistID)
|
localItems[i] = localdb.LocalPricelistItem{
|
||||||
|
PricelistID: localPricelistID,
|
||||||
|
LotName: item.LotName,
|
||||||
|
Price: item.Price,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.localDB.SaveLocalPricelistItems(localItems); err != nil {
|
if err := s.localDB.SaveLocalPricelistItems(localItems); err != nil {
|
||||||
@@ -761,10 +418,6 @@ 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()
|
removed, err := s.localDB.PurgeOrphanConfigurationPendingChanges()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("failed to purge orphan configuration pending changes", "error", err)
|
slog.Warn("failed to purge orphan configuration pending changes", "error", err)
|
||||||
@@ -858,36 +511,23 @@ func (s *Service) pushProjectChange(change *localdb.PendingChange) error {
|
|||||||
projectRepo := repository.NewProjectRepository(mariaDB)
|
projectRepo := repository.NewProjectRepository(mariaDB)
|
||||||
project := payload.Snapshot
|
project := payload.Snapshot
|
||||||
project.UUID = payload.ProjectUUID
|
project.UUID = payload.ProjectUUID
|
||||||
if strings.TrimSpace(project.Code) == "" {
|
|
||||||
project.Code = strings.TrimSpace(derefString(project.Name))
|
|
||||||
if project.Code == "" {
|
|
||||||
project.Code = project.UUID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try upsert by UUID first
|
serverProject, err := projectRepo.GetByUUID(project.UUID)
|
||||||
err = projectRepo.UpsertByUUID(&project)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Check if it's a duplicate (code, variant) constraint violation
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
// In this case, find existing project with same (code, variant) and link to it
|
if createErr := projectRepo.Create(&project); createErr != nil {
|
||||||
var existing models.Project
|
return fmt.Errorf("create project on server: %w", createErr)
|
||||||
lookupErr := mariaDB.Where("code = ? AND variant = ?", project.Code, project.Variant).First(&existing).Error
|
}
|
||||||
if lookupErr == nil {
|
|
||||||
// Found duplicate - link local project to existing server project
|
|
||||||
slog.Info("project duplicate found, linking to existing",
|
|
||||||
"local_uuid", project.UUID,
|
|
||||||
"server_uuid", existing.UUID,
|
|
||||||
"server_id", existing.ID,
|
|
||||||
"code", project.Code,
|
|
||||||
"variant", project.Variant)
|
|
||||||
project.ID = existing.ID
|
|
||||||
} else {
|
} else {
|
||||||
// Not a duplicate issue, return original error
|
return fmt.Errorf("get project on server: %w", err)
|
||||||
return fmt.Errorf("upsert project on server: %w", err)
|
}
|
||||||
|
} else {
|
||||||
|
project.ID = serverProject.ID
|
||||||
|
if updateErr := projectRepo.Update(&project); updateErr != nil {
|
||||||
|
return fmt.Errorf("update project on server: %w", updateErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update local project with server ID
|
|
||||||
localProject, localErr := s.localDB.GetProjectByUUID(project.UUID)
|
localProject, localErr := s.localDB.GetProjectByUUID(project.UUID)
|
||||||
if localErr == nil {
|
if localErr == nil {
|
||||||
if project.ID > 0 {
|
if project.ID > 0 {
|
||||||
@@ -903,17 +543,6 @@ func (s *Service) pushProjectChange(change *localdb.PendingChange) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func derefString(value *string) string {
|
|
||||||
if value == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return *value
|
|
||||||
}
|
|
||||||
|
|
||||||
func ptrString(value string) *string {
|
|
||||||
return &value
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeProjectChangePayload(change *localdb.PendingChange) (ProjectChangePayload, error) {
|
func decodeProjectChangePayload(change *localdb.PendingChange) (ProjectChangePayload, error) {
|
||||||
var payload ProjectChangePayload
|
var payload ProjectChangePayload
|
||||||
if err := json.Unmarshal([]byte(change.Payload), &payload); err == nil && payload.ProjectUUID != "" {
|
if err := json.Unmarshal([]byte(change.Payload), &payload); err == nil && payload.ProjectUUID != "" {
|
||||||
@@ -981,9 +610,6 @@ func (s *Service) pushConfigurationCreate(change *localdb.PendingChange) error {
|
|||||||
if err := s.ensureConfigurationProject(mariaDB, &cfg); err != nil {
|
if err := s.ensureConfigurationProject(mariaDB, &cfg); err != nil {
|
||||||
return fmt.Errorf("resolve configuration project: %w", err)
|
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 {
|
||||||
@@ -1042,9 +668,6 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
|
|||||||
if err := s.ensureConfigurationProject(mariaDB, &cfg); err != nil {
|
if err := s.ensureConfigurationProject(mariaDB, &cfg); err != nil {
|
||||||
return fmt.Errorf("resolve configuration project: %w", err)
|
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
|
||||||
@@ -1055,34 +678,15 @@ 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
|
||||||
// If not found (e.g. stale create was skipped), create it from current snapshot.
|
serverCfg, err := configRepo.GetByUUID(cfg.UUID)
|
||||||
serverCfg, getErr := configRepo.GetByUUID(cfg.UUID)
|
if err != nil {
|
||||||
if getErr != nil {
|
return fmt.Errorf("configuration not yet synced to server: %w", err)
|
||||||
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 := cfg.ID
|
serverID := serverCfg.ID
|
||||||
localCfg.ServerID = &serverID
|
localCfg.ServerID = &serverID
|
||||||
s.localDB.SaveConfiguration(localCfg)
|
s.localDB.SaveConfiguration(localCfg)
|
||||||
} else {
|
} else {
|
||||||
@@ -1158,7 +762,7 @@ func (s *Service) ensureConfigurationProject(mariaDB *gorm.DB, cfg *models.Confi
|
|||||||
if modelProject.OwnerUsername == "" {
|
if modelProject.OwnerUsername == "" {
|
||||||
modelProject.OwnerUsername = cfg.OwnerUsername
|
modelProject.OwnerUsername = cfg.OwnerUsername
|
||||||
}
|
}
|
||||||
if createErr := projectRepo.UpsertByUUID(modelProject); createErr != nil {
|
if createErr := projectRepo.Create(modelProject); createErr != nil {
|
||||||
return createErr
|
return createErr
|
||||||
}
|
}
|
||||||
if modelProject.ID > 0 {
|
if modelProject.ID > 0 {
|
||||||
@@ -1184,8 +788,7 @@ func (s *Service) ensureConfigurationProject(mariaDB *gorm.DB, cfg *models.Confi
|
|||||||
systemProject = &models.Project{
|
systemProject = &models.Project{
|
||||||
UUID: uuid.NewString(),
|
UUID: uuid.NewString(),
|
||||||
OwnerUsername: "",
|
OwnerUsername: "",
|
||||||
Code: "Без проекта",
|
Name: "Без проекта",
|
||||||
Name: ptrString("Без проекта"),
|
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
IsSystem: true,
|
IsSystem: true,
|
||||||
}
|
}
|
||||||
@@ -1198,29 +801,6 @@ func (s *Service) ensureConfigurationProject(mariaDB *gorm.DB, cfg *models.Confi
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ensureConfigurationPricelist(mariaDB *gorm.DB, cfg *models.Configuration) error {
|
|
||||||
if cfg == nil {
|
|
||||||
return fmt.Errorf("configuration is nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
|
||||||
|
|
||||||
if cfg.PricelistID != nil && *cfg.PricelistID > 0 {
|
|
||||||
if _, err := pricelistRepo.GetByID(*cfg.PricelistID); err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
latest, err := pricelistRepo.GetLatestActive()
|
|
||||||
if err != nil {
|
|
||||||
cfg.PricelistID = nil
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.PricelistID = &latest.ID
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) pushConfigurationRollback(change *localdb.PendingChange) error {
|
func (s *Service) pushConfigurationRollback(change *localdb.PendingChange) error {
|
||||||
// Last-write-wins for now: rollback is pushed as an update with rollback metadata.
|
// Last-write-wins for now: rollback is pushed as an update with rollback metadata.
|
||||||
return s.pushConfigurationUpdate(change)
|
return s.pushConfigurationUpdate(change)
|
||||||
@@ -1268,7 +848,6 @@ 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
|
||||||
@@ -1306,7 +885,6 @@ func decodeConfigurationChangePayload(change *localdb.PendingChange) (Configurat
|
|||||||
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,
|
ProjectUUID: cfg.ProjectUUID,
|
||||||
PricelistID: cfg.PricelistID,
|
|
||||||
Operation: change.Operation,
|
Operation: change.Operation,
|
||||||
ConflictPolicy: "last_write_wins",
|
ConflictPolicy: "last_write_wins",
|
||||||
Snapshot: cfg,
|
Snapshot: cfg,
|
||||||
@@ -1349,21 +927,6 @@ func (s *Service) loadCurrentConfigurationState(configurationUUID string) (model
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if currentVersionNo == 0 {
|
|
||||||
if err := s.repairMissingConfigurationVersion(localCfg); err != nil {
|
|
||||||
return models.Configuration{}, "", 0, fmt.Errorf("repair missing configuration version: %w", err)
|
|
||||||
}
|
|
||||||
var latest localdb.LocalConfigurationVersion
|
|
||||||
err = s.localDB.DB().
|
|
||||||
Where("configuration_uuid = ?", configurationUUID).
|
|
||||||
Order("version_no DESC").
|
|
||||||
First(&latest).Error
|
|
||||||
if err == nil {
|
|
||||||
currentVersionNo = latest.VersionNo
|
|
||||||
currentVersionID = latest.ID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if currentVersionNo == 0 {
|
if currentVersionNo == 0 {
|
||||||
return models.Configuration{}, "", 0, fmt.Errorf("no local configuration version found for %s", configurationUUID)
|
return models.Configuration{}, "", 0, fmt.Errorf("no local configuration version found for %s", configurationUUID)
|
||||||
}
|
}
|
||||||
@@ -1371,64 +934,6 @@ func (s *Service) loadCurrentConfigurationState(configurationUUID string) (model
|
|||||||
return cfg, currentVersionID, currentVersionNo, nil
|
return cfg, currentVersionID, currentVersionNo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) repairMissingConfigurationVersion(localCfg *localdb.LocalConfiguration) error {
|
|
||||||
if localCfg == nil {
|
|
||||||
return fmt.Errorf("local configuration is nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
|
||||||
var cfg localdb.LocalConfiguration
|
|
||||||
if err := tx.Where("uuid = ?", localCfg.UUID).First(&cfg).Error; err != nil {
|
|
||||||
return fmt.Errorf("load local configuration: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If versions exist, just make sure current_version_id is set.
|
|
||||||
var latest localdb.LocalConfigurationVersion
|
|
||||||
if err := tx.Where("configuration_uuid = ?", cfg.UUID).
|
|
||||||
Order("version_no DESC").
|
|
||||||
First(&latest).Error; err == nil {
|
|
||||||
if cfg.CurrentVersionID == nil || *cfg.CurrentVersionID == "" {
|
|
||||||
if err := tx.Model(&localdb.LocalConfiguration{}).
|
|
||||||
Where("uuid = ?", cfg.UUID).
|
|
||||||
Update("current_version_id", latest.ID).Error; err != nil {
|
|
||||||
return fmt.Errorf("set current version id: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return fmt.Errorf("load latest version: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshot, err := localdb.BuildConfigurationSnapshot(&cfg)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("build configuration snapshot: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
note := "Auto-repaired missing local version"
|
|
||||||
version := localdb.LocalConfigurationVersion{
|
|
||||||
ID: uuid.NewString(),
|
|
||||||
ConfigurationUUID: cfg.UUID,
|
|
||||||
VersionNo: 1,
|
|
||||||
Data: snapshot,
|
|
||||||
ChangeNote: ¬e,
|
|
||||||
AppVersion: appmeta.Version(),
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Create(&version).Error; err != nil {
|
|
||||||
return fmt.Errorf("create initial version: %w", err)
|
|
||||||
}
|
|
||||||
if err := tx.Model(&localdb.LocalConfiguration{}).
|
|
||||||
Where("uuid = ?", cfg.UUID).
|
|
||||||
Update("current_version_id", version.ID).Error; err != nil {
|
|
||||||
return fmt.Errorf("set current version id: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Warn("repaired missing local configuration version", "uuid", cfg.UUID, "version_no", version.VersionNo)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: prepared for future conflict resolution:
|
// NOTE: prepared for future conflict resolution:
|
||||||
// when server starts storing version metadata, we can compare payload.CurrentVersionNo
|
// when server starts storing version metadata, we can compare payload.CurrentVersionNo
|
||||||
// against remote version and branch into custom strategies. For now use last-write-wins.
|
// against remote version and branch into custom strategies. For now use last-write-wins.
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
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 TestSyncPricelists_BackfillsLotCategoryForUsedPricelistItems(t *testing.T) {
|
|
||||||
local := newLocalDBForSyncTest(t)
|
|
||||||
serverDB := newServerDBForSyncTest(t)
|
|
||||||
|
|
||||||
if err := serverDB.AutoMigrate(
|
|
||||||
&models.Pricelist{},
|
|
||||||
&models.PricelistItem{},
|
|
||||||
&models.Lot{},
|
|
||||||
&models.LotPartnumber{},
|
|
||||||
&models.StockLog{},
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("migrate server tables: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
serverPL := models.Pricelist{
|
|
||||||
Source: "estimate",
|
|
||||||
Version: "2026-02-11-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",
|
|
||||||
LotCategory: "CPU",
|
|
||||||
Price: 10,
|
|
||||||
PriceMethod: "",
|
|
||||||
MetaPrices: "",
|
|
||||||
ManualPrice: nil,
|
|
||||||
AvailableQty: nil,
|
|
||||||
}).Error; err != nil {
|
|
||||||
t.Fatalf("create server pricelist item: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
|
||||||
ServerID: serverPL.ID,
|
|
||||||
Source: serverPL.Source,
|
|
||||||
Version: serverPL.Version,
|
|
||||||
Name: serverPL.Notification,
|
|
||||||
CreatedAt: serverPL.CreatedAt,
|
|
||||||
SyncedAt: time.Now(),
|
|
||||||
IsUsed: false,
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("seed local pricelist: %v", err)
|
|
||||||
}
|
|
||||||
localPL, err := local.GetLocalPricelistByServerID(serverPL.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get local pricelist: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
|
||||||
{
|
|
||||||
PricelistID: localPL.ID,
|
|
||||||
LotName: "CPU_A",
|
|
||||||
LotCategory: "",
|
|
||||||
Price: 10,
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("seed local pricelist items: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := local.SaveConfiguration(&localdb.LocalConfiguration{
|
|
||||||
UUID: "cfg-1",
|
|
||||||
OriginalUsername: "tester",
|
|
||||||
Name: "cfg",
|
|
||||||
Items: localdb.LocalConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 10}},
|
|
||||||
IsActive: true,
|
|
||||||
PricelistID: &serverPL.ID,
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
items, err := local.GetLocalPricelistItems(localPL.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("load local items: %v", err)
|
|
||||||
}
|
|
||||||
if len(items) != 1 {
|
|
||||||
t.Fatalf("expected 1 local item, got %d", len(items))
|
|
||||||
}
|
|
||||||
if items[0].LotCategory != "CPU" {
|
|
||||||
t.Fatalf("expected lot_category backfilled to CPU, got %q", items[0].LotCategory)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -23,7 +23,7 @@ func TestPushPendingChangesProjectsBeforeConfigurations(t *testing.T) {
|
|||||||
projectService := services.NewProjectService(local)
|
projectService := services.NewProjectService(local)
|
||||||
configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false })
|
configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false })
|
||||||
|
|
||||||
project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: ptrString("Project A"), Code: "PRJ-A"})
|
project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: "Project A"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create project: %v", err)
|
t.Fatalf("create project: %v", err)
|
||||||
}
|
}
|
||||||
@@ -65,54 +65,6 @@ func TestPushPendingChangesProjectsBeforeConfigurations(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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: ptrString("Project v1"), Code: "PRJ-V1"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create project: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := projectService.Update(project.UUID, "tester", &services.UpdateProjectRequest{Name: ptrString("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 == nil || *serverProject.Name != "Project v2" {
|
|
||||||
t.Fatalf("expected latest project name, got %v", 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) {
|
func TestPushPendingChangesSkipsStaleUpdateAndAppliesLatest(t *testing.T) {
|
||||||
local := newLocalDBForSyncTest(t)
|
local := newLocalDBForSyncTest(t)
|
||||||
serverDB := newServerDBForSyncTest(t)
|
serverDB := newServerDBForSyncTest(t)
|
||||||
@@ -250,57 +202,6 @@ func TestPushPendingChangesCreateIsIdempotent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
func newLocalDBForSyncTest(t *testing.T) *localdb.LocalDB {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
localPath := filepath.Join(t.TempDir(), "local.db")
|
localPath := filepath.Join(t.TempDir(), "local.db")
|
||||||
@@ -324,10 +225,7 @@ CREATE TABLE qt_projects (
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
uuid TEXT NOT NULL UNIQUE,
|
uuid TEXT NOT NULL UNIQUE,
|
||||||
owner_username TEXT NOT NULL,
|
owner_username TEXT NOT NULL,
|
||||||
code TEXT NOT NULL,
|
|
||||||
variant TEXT NOT NULL DEFAULT '',
|
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
tracker_url TEXT NULL,
|
|
||||||
is_active INTEGER NOT NULL DEFAULT 1,
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
is_system INTEGER NOT NULL DEFAULT 0,
|
is_system INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at DATETIME,
|
created_at DATETIME,
|
||||||
@@ -335,9 +233,6 @@ CREATE TABLE qt_projects (
|
|||||||
);`).Error; err != nil {
|
);`).Error; err != nil {
|
||||||
t.Fatalf("create qt_projects: %v", err)
|
t.Fatalf("create qt_projects: %v", err)
|
||||||
}
|
}
|
||||||
if err := db.Exec(`CREATE UNIQUE INDEX idx_qt_projects_code_variant ON qt_projects(code, variant);`).Error; err != nil {
|
|
||||||
t.Fatalf("create qt_projects index: %v", err)
|
|
||||||
}
|
|
||||||
if err := db.Exec(`
|
if err := db.Exec(`
|
||||||
CREATE TABLE qt_configurations (
|
CREATE TABLE qt_configurations (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -353,14 +248,6 @@ CREATE TABLE qt_configurations (
|
|||||||
notes TEXT NULL,
|
notes TEXT NULL,
|
||||||
is_template INTEGER NOT NULL DEFAULT 0,
|
is_template INTEGER NOT NULL DEFAULT 0,
|
||||||
server_count INTEGER NOT NULL DEFAULT 1,
|
server_count INTEGER NOT NULL DEFAULT 1,
|
||||||
server_model TEXT NULL,
|
|
||||||
support_code TEXT NULL,
|
|
||||||
article TEXT NULL,
|
|
||||||
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,
|
price_updated_at DATETIME NULL,
|
||||||
created_at DATETIME
|
created_at DATETIME
|
||||||
);`).Error; err != nil {
|
);`).Error; err != nil {
|
||||||
@@ -369,10 +256,6 @@ CREATE TABLE qt_configurations (
|
|||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
func ptrString(value string) *string {
|
|
||||||
return &value
|
|
||||||
}
|
|
||||||
|
|
||||||
func getCurrentVersionInfo(t *testing.T, local *localdb.LocalDB, configurationUUID string, currentVersionID *string) (int, string) {
|
func getCurrentVersionInfo(t *testing.T, local *localdb.LocalDB, configurationUUID string, currentVersionID *string) (int, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if currentVersionID == nil || *currentVersionID == "" {
|
if currentVersionID == nil || *currentVersionID == "" {
|
||||||
|
|||||||
@@ -71,15 +71,6 @@ 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 {
|
||||||
@@ -92,11 +83,7 @@ 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")
|
||||||
}
|
}
|
||||||
|
|||||||
93
markdown
93
markdown
@@ -1,93 +0,0 @@
|
|||||||
<p><strong>ТЕХНИЧЕСКОЕ ЗАДАНИЕ</strong></p>
|
|
||||||
<p>1. Требования к продукции</p>
|
|
||||||
<p>Поставляемое оборудование должно быть новым, оригинальным, не бывшим
|
|
||||||
в употреблении, не восстановленным. Гарантийный срок — не менее 12
|
|
||||||
месяцев с момента поставки. Все компоненты, включая процессоры, память,
|
|
||||||
накопители и контроллеры, должны быть совместимы и предварительно
|
|
||||||
протестированы на совместимость и производительность в рамках единой
|
|
||||||
системы.</p>
|
|
||||||
<p>2. Количественные и качественные характеристики</p>
|
|
||||||
<p>2.1. Базовые требования к серверной платформе:</p>
|
|
||||||
<p>Модель (артикул): Сервер в конфигурации с шасси, поддерживающим 12
|
|
||||||
отсека 2.5" для накопителей NVMe U.2/U.3.</p>
|
|
||||||
<p>Форм-фактор: 2U для установки в стойку.</p>
|
|
||||||
<p>Кол-во процессорных сокетов: 1.</p>
|
|
||||||
<p>2.2. Требования к процессорам (CPU):</p>
|
|
||||||
<p>Количество: 1 шт.</p>
|
|
||||||
<p>Модель/семейство: 256t x AMD EPYC 9755 2.7 GHz 128c-Core Processor
|
|
||||||
Объём кэша L3: 512MB Техпроцесс: 4 нм, архитектура процессора: Zen-5
|
|
||||||
(Turin).</p>
|
|
||||||
<p>Минимальная базовая тактовая частота: 2.7 ГГц.</p>
|
|
||||||
<p>Максимальная частота работы процессора (Turboboost): 4.1 GHz</p>
|
|
||||||
<p>Для обеспечения полной производительности всех 12 накопителей NVMe и
|
|
||||||
сетевых адаптеров, процессор и системная платформа в целом должны
|
|
||||||
обеспечивать достаточно линий PCIe 5.0.</p>
|
|
||||||
<p>2.3. Требования к оперативной памяти (RAM):</p>
|
|
||||||
<p>Тип памяти: DDR5 с коррекцией ошибок (ECC) RDIMM 6000Mhz.</p>
|
|
||||||
<p>Минимальный объем оперативной памяти: 2048 ГБ.</p>
|
|
||||||
<p>Конфигурация: Модули памяти должны быть установлены в оптимальной
|
|
||||||
конфигурации для обеспечения полной пропускной способности всех линий
|
|
||||||
PCIe 5.0 от NVMe-накопителей. Платформа должна поддерживать установку не
|
|
||||||
менее 16 модулей DDR5 ECC REG 6000Mhz для последующего
|
|
||||||
масштабирования.</p>
|
|
||||||
<p>Поддерживаемая частота: Не менее 6000 МТ/с.</p>
|
|
||||||
<p>Одобренные модули оперативной памяти - Samsung/Micron/Hynix, DDR5,
|
|
||||||
64GB, RDIMM, ECC</p>
|
|
||||||
<p>2.4 Требования к системе хранения данных:</p>
|
|
||||||
<p>Конфигурация шасси: Обязательна поставка в конфигурации с 12 отсеками
|
|
||||||
2.5" под горячую замену, поддерживающими интерфейс NVMe через PCIe 5.0
|
|
||||||
x4 и 2 отсеками 2.5"/М.2 под горячую замену, поддерживающими SATA.</p>
|
|
||||||
<p>Дополнительно (для ОС): Поддержка установки 2x M.2 NVMe накопителей в
|
|
||||||
dedicated-слотах на материнской плате под операционную систему отдельно
|
|
||||||
от основного хранилища данных.</p>
|
|
||||||
<p>2.5. Требования к сетевым интерфейсам (NIC):</p>
|
|
||||||
<p>Слоты расширения сети: Наличие не менее 1 слотов OCP 3.0 SFF для
|
|
||||||
установки специализированных сетевых адаптеров.</p>
|
|
||||||
<p>Дополнительные сетевые адаптеры (обязательная поставка): Один сетевой
|
|
||||||
адаптер OCP 3.0 с 2 портами 25 Гбит/с Ethernet Intel 810 или Mellanox
|
|
||||||
CX-6.</p>
|
|
||||||
<p>Встроенные порты управления: порт 1 Гбит/с Ethernet (RJ-45) для
|
|
||||||
модуля управления iBMC.</p>
|
|
||||||
<p>2.6. Требования к интерфейсам расширения (PCIe):</p>
|
|
||||||
<p>Количество слотов PCIe: Конфигурация с 12 дисками NVMe использует
|
|
||||||
большую часть линий PCIe. Тем не менее, не менее слотов PCIe 5.0 должны
|
|
||||||
оставаться свободными для будущего расширения (например, установки
|
|
||||||
дополнительных сетевых карт или FPGA/GPU для специфических задач).</p>
|
|
||||||
<p>Шинная архитектура: Поставщик должен предоставить схему распределения
|
|
||||||
линий PCIe 5.0 между процессором контроллером RAID и слотами расширения,
|
|
||||||
подтверждающую отсутствие узких мест (bottlenecks).</p>
|
|
||||||
<p>2.7. Требования к системе управления:</p>
|
|
||||||
<p>Внеполосный (out-of-band) модуль управления: Наличие выделенного чипа
|
|
||||||
iBMC.</p>
|
|
||||||
<p>Интеллектуальные функции: Критически важна поддержка детального
|
|
||||||
мониторинга состояния NVMe-накопителей (SMART, температура, износ,
|
|
||||||
прогнозирование сбоев) через интерфейс iBMC и поддержка технологии
|
|
||||||
горячей замены NVMe-накопителей.</p>
|
|
||||||
<p>Наличие безагентного КВМ (HTML5)</p>
|
|
||||||
<p>Желательна поддержка shared LAN (через NCSI OCPv3 разъем) с
|
|
||||||
тегированием VLAN, настройка по умолчанию DHCP IPv4</p>
|
|
||||||
<p>Управление параметрами работы сервера (режим работы вентиляторов,
|
|
||||||
потребление энергии, итд).</p>
|
|
||||||
<p>Наличие 2х видов логирования:</p>
|
|
||||||
<p>Все аппаратные события, включая ECC ошибки по памяти, ошибки PCIe,
|
|
||||||
SATA (IPMI/Hardware Event Log)</p>
|
|
||||||
<p>Все сессии аутентификации и изменения системных параметров
|
|
||||||
(Audit/Security Log)</p>
|
|
||||||
<p>Наличие функционала обновления прошивок сервера (BIOS, BMC, CPLD
|
|
||||||
(опционально)) с сохранением и без сохранения настроек.</p>
|
|
||||||
<p>2.8. Требования к системе питания и охлаждения:</p>
|
|
||||||
<p>Блоки питания (PSU):</p>
|
|
||||||
<p>Количество: 2 шт. (резервирование 1+1) с возможностью горячей
|
|
||||||
замены.</p>
|
|
||||||
<p>Номинальная мощность каждого: Минимум 1200 Вт с сертификацией 80 Plus
|
|
||||||
Platinum/Titanium. Мощность должна быть достаточной для одновременной
|
|
||||||
пиковой нагрузки от процессора, 12 NVMe-дисков и прочих компонентов.</p>
|
|
||||||
<p>Система охлаждения: не менее N+1 резервирование вентиляторов.</p>
|
|
||||||
<p>3. Упаковка и маркировка:</p>
|
|
||||||
<p>Оборудование должно быть упаковано так, чтобы предотвратить
|
|
||||||
повреждение при транспортировке.</p>
|
|
||||||
<p>4. Требования к комплектации:</p>
|
|
||||||
<p>Рельсовый комплект - без инструментов с горизонтальной загрузкой</p>
|
|
||||||
<p>Оборудование - C19-C20 или С13-С14 кабели питания 1-2 m в зависимости
|
|
||||||
от БП, 19 " комплект для монтажа в стойку, комплект винтов, все отсеки с
|
|
||||||
корзинами.</p>
|
|
||||||
41
memory.md
41
memory.md
@@ -1,41 +0,0 @@
|
|||||||
# Changes summary (2026-02-11)
|
|
||||||
|
|
||||||
Implemented strict `lot_category` flow using `pricelist_items.lot_category` only (no parsing from `lot_name`), plus local caching and backfill:
|
|
||||||
|
|
||||||
1. Local DB schema + migrations
|
|
||||||
- Added `lot_category` column to `local_pricelist_items` via `LocalPricelistItem` model.
|
|
||||||
- Added local migration `2026_02_11_local_pricelist_item_category` to add the column if missing and create indexes:
|
|
||||||
- `idx_local_pricelist_items_pricelist_lot (pricelist_id, lot_name)`
|
|
||||||
- `idx_local_pricelist_items_lot_category (lot_category)`
|
|
||||||
|
|
||||||
2. Server model/repository
|
|
||||||
- Added `LotCategory` field to `models.PricelistItem`.
|
|
||||||
- `PricelistRepository.GetItems` now sets `Category` from `LotCategory` (no parsing from `lot_name`).
|
|
||||||
|
|
||||||
3. Sync + local DB helpers
|
|
||||||
- `SyncPricelistItems` now saves `lot_category` into local cache via `PricelistItemToLocal`.
|
|
||||||
- Added `LocalDB.CountLocalPricelistItemsWithEmptyCategory` and `LocalDB.ReplaceLocalPricelistItems`.
|
|
||||||
- Added `LocalDB.GetLocalLotCategoriesByServerPricelistID` for strict category lookup.
|
|
||||||
- Added `SyncPricelists` backfill step: for used active pricelists with empty categories, force refresh items from server.
|
|
||||||
|
|
||||||
4. API handler
|
|
||||||
- `GET /api/pricelists/:id/items` returns `category` from `local_pricelist_items.lot_category` (no parsing from `lot_name`).
|
|
||||||
|
|
||||||
5. Article category foundation
|
|
||||||
- New package `internal/article`:
|
|
||||||
- `ResolveLotCategoriesStrict` pulls categories from local pricelist items and errors on missing category.
|
|
||||||
- `GroupForLotCategory` maps only allowed codes (CPU/MEM/GPU/M2/SSD/HDD/EDSFF/HHHL/NIC/HCA/DPU/PSU/PS) to article groups; excludes `SFP`.
|
|
||||||
- Error type `MissingCategoryForLotError` with base `ErrMissingCategoryForLot`.
|
|
||||||
|
|
||||||
6. Tests
|
|
||||||
- Added unit tests for converters and article category resolver.
|
|
||||||
- Added handler test to ensure `/api/pricelists/:id/items` returns `lot_category`.
|
|
||||||
- Added sync test for category backfill on used pricelist items.
|
|
||||||
- `go test ./...` passed.
|
|
||||||
|
|
||||||
Additional fixes (2026-02-11):
|
|
||||||
- Fixed article parsing bug: CPU/GPU parsers were swapped in `internal/article/generator.go`. CPU now uses last token from CPU lot; GPU uses model+memory from `GPU_vendor_model_mem_iface`.
|
|
||||||
- Adjusted configurator base tab layout to align labels on the same row (separate label row + input row grid).
|
|
||||||
|
|
||||||
UI rule (2026-02-19):
|
|
||||||
- In all breadcrumbs, truncate long specification/configuration names to 16 characters using ellipsis.
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
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)
|
|
||||||
);
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
ALTER TABLE qt_pricelist_sync_status
|
|
||||||
ADD COLUMN IF NOT EXISTS app_version VARCHAR(64) NULL;
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
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, '')) <> '';
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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);
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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)
|
|
||||||
);
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
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)
|
|
||||||
);
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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)
|
|
||||||
);
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user