Compare commits
8 Commits
v1.3.0
...
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
|
||||
config.yaml
|
||||
.env
|
||||
.env.*
|
||||
*.pem
|
||||
*.key
|
||||
*.p12
|
||||
*.pfx
|
||||
*.crt
|
||||
id_rsa
|
||||
id_rsa.*
|
||||
secrets.yaml
|
||||
secrets.yml
|
||||
|
||||
# Local SQLite database (contains encrypted credentials)
|
||||
/data/*.db
|
||||
@@ -23,30 +12,10 @@ secrets.yml
|
||||
/importer
|
||||
/cron
|
||||
/bin/
|
||||
qfs
|
||||
|
||||
# Local Go build cache used in sandboxed runs
|
||||
.gocache/
|
||||
|
||||
# Local tooling state
|
||||
.claude/
|
||||
|
||||
# Editor settings
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Temp and logs
|
||||
*.tmp
|
||||
*.temp
|
||||
*.log
|
||||
|
||||
# Go test/build artifacts
|
||||
*.out
|
||||
*.test
|
||||
coverage/
|
||||
|
||||
# ---> macOS
|
||||
# General
|
||||
.DS_Store
|
||||
@@ -75,7 +44,4 @@ Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
# Release artifacts (binaries, archives, checksums), but DO track releases/memory/ for changelog
|
||||
releases/*
|
||||
!releases/memory/
|
||||
!releases/memory/**
|
||||
releases/
|
||||
|
||||
204
CLAUDE.md
204
CLAUDE.md
@@ -1,83 +1,163 @@
|
||||
# QuoteForge - Claude Code Instructions
|
||||
|
||||
## Overview
|
||||
Корпоративный конфигуратор серверов с offline-first архитектурой.
|
||||
Приложение работает через локальную SQLite базу, синхронизация с MariaDB выполняется фоново.
|
||||
Корпоративный конфигуратор серверов и формирование КП. MariaDB (RFQ_LOG) + SQLite для оффлайн.
|
||||
|
||||
## Product Scope
|
||||
- Конфигуратор компонентов и расчёт КП
|
||||
- Проекты и конфигурации
|
||||
- Read-only просмотр прайслистов из локального кэша
|
||||
- Sync (pull компонентов/прайслистов, push локальных изменений)
|
||||
## Development Phases
|
||||
|
||||
Из области исключены:
|
||||
- admin pricing UI/API
|
||||
- stock import
|
||||
- alerts
|
||||
- cron/importer утилиты
|
||||
### Phase 1: Pricelists in MariaDB ✅ DONE
|
||||
### Phase 2: Local SQLite Database ✅ DONE
|
||||
|
||||
## Architecture
|
||||
- Local-first: чтение и запись происходят в SQLite
|
||||
- MariaDB используется как сервер синхронизации
|
||||
- Background worker: периодический sync push+pull
|
||||
### Phase 2.5: Full Offline Mode 🔶 IN PROGRESS
|
||||
**Local-first architecture:** приложение ВСЕГДА работает с SQLite, MariaDB только для синхронизации.
|
||||
|
||||
## Guardrails
|
||||
- Не возвращать в проект удалённые legacy-разделы: cron jobs, importer utility, admin pricing, alerts, stock import.
|
||||
- Runtime-конфиг читается из user state (`config.yaml`) или через `-config` / `QFS_CONFIG_PATH`; не хранить рабочий `config.yaml` в репозитории.
|
||||
- `config.example.yaml` остаётся единственным шаблоном конфигурации в репо.
|
||||
- Любые изменения в sync должны сохранять local-first поведение: локальные CRUD не блокируются из-за недоступности MariaDB.
|
||||
**Принцип работы:**
|
||||
- ВСЕ операции (CRUD) выполняются в SQLite
|
||||
- При создании конфигурации:
|
||||
1. Если online → проверить новые прайслисты на сервере → скачать если есть
|
||||
2. Далее работаем с local_pricelists (и online, и offline одинаково)
|
||||
- Background sync: push pending_changes → pull updates
|
||||
|
||||
## Key SQLite Data
|
||||
- `connection_settings`
|
||||
- `local_components`
|
||||
- `local_pricelists`, `local_pricelist_items`
|
||||
- `local_configurations`
|
||||
- `local_projects`
|
||||
- `pending_changes`
|
||||
**DONE:**
|
||||
- ✅ Sync queue table (pending_changes) - `internal/localdb/models.go`
|
||||
- ✅ Model converters: MariaDB ↔ SQLite - `internal/localdb/converters.go`
|
||||
- ✅ LocalConfigurationService: все CRUD через SQLite - `internal/services/local_configuration.go`
|
||||
- ✅ Pre-create pricelist check: `SyncPricelistsIfNeeded()` - `internal/services/sync/service.go`
|
||||
- ✅ Push pending changes: `PushPendingChanges()` - sync service + handlers
|
||||
- ✅ Sync API endpoints: `/api/sync/push`, `/pending/count`, `/pending`
|
||||
- ✅ Integrate LocalConfigurationService in main.go (replace ConfigurationService)
|
||||
- ✅ Add routes for new sync endpoints (`/api/sync/push`, `/pending/count`, `/pending`)
|
||||
- ✅ ConfigurationGetter interface for handler compatibility
|
||||
- ✅ Background sync worker: auto-sync every 5min (push + pull) - `internal/services/sync/worker.go`
|
||||
- ✅ UI: sync status indicator (pending badge + sync button + offline/online dot) - `web/templates/partials/sync_status.html`
|
||||
- ✅ RefreshPrices for local mode:
|
||||
- `RefreshPrices()` / `RefreshPricesNoAuth()` в `local_configuration.go`
|
||||
- Берёт цены из `local_components.current_price`
|
||||
- Graceful degradation при отсутствии компонента
|
||||
- Добавлено поле `price_updated_at` в `LocalConfiguration` (models.go:72)
|
||||
- Обновлены converters для PriceUpdatedAt
|
||||
- UI кнопка "Пересчитать цену" работает offline/online
|
||||
- ✅ Fixed sync bugs:
|
||||
- Duplicate entry error при update конфигураций (`sync/service.go:334-365`)
|
||||
- pushConfigurationUpdate теперь проверяет наличие server_id перед update
|
||||
- Если нет ID → получает из LocalConfiguration.ServerID или ищет на сервере
|
||||
- Fixed setup.go: `settings.Password` → `settings.PasswordEncrypted`
|
||||
|
||||
**TODO:**
|
||||
- ❌ Conflict resolution (Phase 4, last-write-wins default)
|
||||
|
||||
### UI Improvements ✅ MOSTLY DONE
|
||||
|
||||
**1. Sync UI + pricelist badge: ✅ DONE**
|
||||
- ✅ `sync_status.html`: SVG иконки Online/Offline (кликабельные → открывают модал)
|
||||
- ✅ Кнопка sync → иконка circular arrows (только full sync)
|
||||
- ✅ Модальное окно "Статус системы" в `base.html` (info о БД, ошибки синхронизации)
|
||||
- ✅ `configs.html`: badge с версией активного прайслиста
|
||||
- ✅ Загрузка через `/api/pricelists/latest` при DOMContentLoaded
|
||||
- ✅ Удалён dropdown с Push changes (упрощение UI)
|
||||
|
||||
**2. Прайслисты → вкладка в "Администратор цен": ✅ DONE**
|
||||
- ✅ `base.html`: убрана ссылка "Прайслисты" из навигации
|
||||
- ✅ `admin_pricing.html`: добавлена вкладка "Прайслисты"
|
||||
- ✅ Логика перенесена из `pricelists.html` (table, create modal, CRUD)
|
||||
- ✅ Route `/pricelists` → редирект на `/admin/pricing?tab=pricelists`
|
||||
- ✅ Поддержка URL param `?tab=pricelists`
|
||||
|
||||
**3. Модал "Настройка цены" - кол-во котировок с учётом периода: ❌ TODO**
|
||||
- Текущее: показывает только общее кол-во котировок
|
||||
- Новое: показывать `N (всего: M)` где N - за выбранный период, M - всего
|
||||
- ❌ `admin_pricing.html`: обновить `#modal-quote-count`
|
||||
- ❌ `admin_pricing_handler.go`: в `/api/admin/pricing/preview` возвращать `quote_count_period` + `quote_count_total`
|
||||
|
||||
**4. Страница настроек: ❌ ОТЛОЖЕНО**
|
||||
- Перенесено в Phase 3 (после основных UI улучшений)
|
||||
|
||||
### Phase 3: Projects and Specifications
|
||||
- qt_projects, qt_specifications tables (MariaDB)
|
||||
- Replace qt_configurations → Project/Specification hierarchy
|
||||
- Fields: opty, customer_requirement, variant, qty, rev
|
||||
- Local projects/specs with server sync
|
||||
|
||||
### Phase 4: Price Versioning
|
||||
- Bind specifications to pricelist versions
|
||||
- Price diff comparison
|
||||
- Auto-cleanup expired pricelists (>1 year, usage_count=0)
|
||||
|
||||
## Tech Stack
|
||||
Go 1.22+ | Gin | GORM | MariaDB 11 | SQLite (glebarez/sqlite) | htmx + Tailwind CDN | excelize
|
||||
|
||||
## Key Tables
|
||||
|
||||
### READ-ONLY (external systems)
|
||||
- `lot` (lot_name PK, lot_description)
|
||||
- `lot_log` (lot, supplier, date, price, quality, comments)
|
||||
- `supplier` (supplier_name PK)
|
||||
|
||||
### MariaDB (qt_* prefix)
|
||||
- `qt_lot_metadata` - component prices, methods, popularity
|
||||
- `qt_categories` - category codes and names
|
||||
- `qt_pricelists` - version snapshots (YYYY-MM-DD-NNN format)
|
||||
- `qt_pricelist_items` - prices per pricelist
|
||||
- `qt_projects` - uuid, opty, customer_requirement, name (Phase 3)
|
||||
- `qt_specifications` - project_id, pricelist_id, variant, rev, qty, items JSON (Phase 3)
|
||||
|
||||
### SQLite (data/quoteforge.db)
|
||||
- `connection_settings` - encrypted DB credentials (PasswordEncrypted field)
|
||||
- `local_pricelists/items` - cached from server
|
||||
- `local_components` - lot cache for offline search (with current_price)
|
||||
- `local_configurations` - UUID, items, price_updated_at, sync_status (pending/synced/conflict), server_id
|
||||
- `local_projects/specifications` - Phase 3
|
||||
- `pending_changes` - sync queue (entity_type, uuid, op, payload, created_at, attempts, last_error)
|
||||
|
||||
## Business Logic
|
||||
|
||||
**Part number parsing:** `CPU_AMD_9654` → category=`CPU`, model=`AMD_9654`
|
||||
|
||||
**Price methods:** manual | median | average | weighted_median
|
||||
|
||||
**Price freshness:** fresh (<30d, ≥3 quotes) | normal (<60d) | stale (<90d) | critical
|
||||
|
||||
**Pricelist version:** `YYYY-MM-DD-NNN` (e.g., `2024-01-31-001`)
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Group | Endpoints |
|
||||
|-------|-----------|
|
||||
| Setup | `GET /setup`, `POST /setup`, `POST /setup/test`, `GET /setup/status` |
|
||||
| Components | `GET /api/components`, `GET /api/components/:lot_name`, `GET /api/categories` |
|
||||
| Quote | `POST /api/quote/validate`, `POST /api/quote/calculate`, `POST /api/quote/price-levels` |
|
||||
| Pricelists (read-only) | `GET /api/pricelists`, `GET /api/pricelists/latest`, `GET /api/pricelists/:id`, `GET /api/pricelists/:id/items`, `GET /api/pricelists/:id/lots` |
|
||||
| Configs | CRUD + refresh/clone/reactivate/rename/project binding via `/api/configs/*` |
|
||||
| Projects | CRUD + nested configs via `/api/projects/*` |
|
||||
| Sync | `GET /api/sync/status`, `GET /api/sync/readiness`, `GET /api/sync/info`, `GET /api/sync/users-status`, `POST /api/sync/components`, `POST /api/sync/pricelists`, `POST /api/sync/all`, `POST /api/sync/push`, `GET /api/sync/pending`, `GET /api/sync/pending/count` |
|
||||
| Export | `POST /api/export/csv` |
|
||||
|
||||
## Web Routes
|
||||
- `/configs`
|
||||
- `/configurator`
|
||||
- `/projects`
|
||||
- `/projects/:uuid`
|
||||
- `/pricelists`
|
||||
- `/pricelists/:id`
|
||||
- `/setup`
|
||||
|
||||
## Release Notes & Change Log
|
||||
Release notes are maintained in `releases/memory/` directory organized by version tags (e.g., `v1.2.1.md`).
|
||||
Before working on the codebase, review the most recent release notes to understand recent changes.
|
||||
- Check `releases/memory/` for detailed changelog between tags
|
||||
- Each release file documents commits, breaking changes, and migration notes
|
||||
| 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
|
||||
# Development
|
||||
go run ./cmd/qfs
|
||||
make run
|
||||
go run ./cmd/qfs # Dev server
|
||||
make run # Dev server (via Makefile)
|
||||
|
||||
# Build
|
||||
make build-release
|
||||
CGO_ENABLED=0 go build -o bin/qfs ./cmd/qfs
|
||||
# Production build
|
||||
make build-release # Optimized build with version (recommended)
|
||||
VERSION=$(git describe --tags --always --dirty)
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=$VERSION" -o bin/qfs ./cmd/qfs
|
||||
|
||||
# Verification
|
||||
go build ./cmd/qfs
|
||||
go vet ./...
|
||||
# Cron jobs
|
||||
go run ./cmd/cron -job=cleanup-pricelists # Remove old unused pricelists
|
||||
go run ./cmd/cron -job=update-prices # Recalculate all prices
|
||||
go run ./cmd/cron -job=update-popularity # Update popularity scores
|
||||
|
||||
# Check version
|
||||
./bin/qfs -version
|
||||
```
|
||||
|
||||
## Code Style
|
||||
- gofmt
|
||||
- structured logging (`slog`)
|
||||
- explicit error wrapping with context
|
||||
- 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
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
@@ -72,12 +72,6 @@ deps:
|
||||
go mod download
|
||||
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:
|
||||
@echo "QuoteForge Server (qfs) - Build Commands"
|
||||
@@ -98,7 +92,6 @@ help:
|
||||
@echo " run Run development server"
|
||||
@echo " watch Run with auto-restart (requires entr)"
|
||||
@echo " deps Install/update dependencies"
|
||||
@echo " install-hooks Install local git hooks (secret scan on commit)"
|
||||
@echo " help Show this help"
|
||||
@echo ""
|
||||
@echo "Current version: $(VERSION)"
|
||||
|
||||
245
README.md
245
README.md
@@ -2,8 +2,7 @@
|
||||
|
||||
**Server Configuration & Quotation Tool**
|
||||
|
||||
QuoteForge — корпоративный инструмент для конфигурирования серверов и формирования коммерческих предложений (КП).
|
||||
Приложение работает в strict local-first режиме: пользовательские операции выполняются через локальную SQLite, MariaDB используется для синхронизации и серверного администрирования прайслистов.
|
||||
QuoteForge — корпоративный инструмент для конфигурирования серверов и формирования коммерческих предложений (КП). Приложение интегрируется с существующей базой данных RFQ_LOG.
|
||||
|
||||

|
||||

|
||||
@@ -17,8 +16,6 @@ QuoteForge — корпоративный инструмент для конфи
|
||||
- 💰 **Автоматический расчёт цен** — актуальные цены на основе истории закупок
|
||||
- 📊 **Экспорт в CSV/XLSX** — готовые спецификации для клиентов
|
||||
- 💾 **Сохранение конфигураций** — история и шаблоны для повторного использования
|
||||
- 🔌 **Полная офлайн-работа** — можно продолжать работу без сети и синхронизировать позже
|
||||
- 🛡️ **Защищенная синхронизация** — sync блокируется preflight-проверкой, если локальная схема не готова
|
||||
|
||||
### Для ценовых администраторов
|
||||
- 📈 **Умный расчёт цен** — медиана, взвешенная медиана, среднее
|
||||
@@ -38,7 +35,7 @@ QuoteForge — корпоративный инструмент для конфи
|
||||
|
||||
- **Backend:** Go 1.22+, Gin, GORM
|
||||
- **Frontend:** HTML, Tailwind CSS, htmx
|
||||
- **Database:** SQLite (runtime/local-first), MariaDB 11+ (sync + server admin)
|
||||
- **Database:** MariaDB 11+
|
||||
- **Export:** excelize (XLSX), encoding/csv
|
||||
|
||||
## Требования
|
||||
@@ -56,13 +53,13 @@ git clone https://github.com/your-company/quoteforge.git
|
||||
cd quoteforge
|
||||
```
|
||||
|
||||
### 2. Настройка runtime-конфига (опционально)
|
||||
### 2. Настройка конфигурации
|
||||
|
||||
`config.yaml` создаётся автоматически при первом старте в той же user-state папке, где находится `qfs.db`.
|
||||
Если найден старый формат, приложение автоматически мигрирует файл в актуальный runtime-формат
|
||||
(оставляя только используемые секции `server` и `logging`).
|
||||
```bash
|
||||
cp config.example.yaml config.yaml
|
||||
```
|
||||
|
||||
При необходимости можно создать/отредактировать файл вручную:
|
||||
Отредактируйте `config.yaml`:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
@@ -70,10 +67,16 @@ server:
|
||||
port: 8080
|
||||
mode: "release"
|
||||
|
||||
logging:
|
||||
level: "info"
|
||||
format: "json"
|
||||
output: "stdout"
|
||||
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. Миграции базы данных
|
||||
@@ -90,100 +93,51 @@ go run ./cmd/qfs -migrate
|
||||
Сначала всегда смотрите preview:
|
||||
|
||||
```bash
|
||||
go run ./cmd/migrate_ops_projects
|
||||
go run ./cmd/migrate_ops_projects -config config.yaml
|
||||
```
|
||||
|
||||
Применение изменений:
|
||||
|
||||
```bash
|
||||
go run ./cmd/migrate_ops_projects -apply
|
||||
go run ./cmd/migrate_ops_projects -config config.yaml -apply
|
||||
```
|
||||
|
||||
Без интерактивного подтверждения:
|
||||
|
||||
```bash
|
||||
go run ./cmd/migrate_ops_projects -apply -yes
|
||||
go run ./cmd/migrate_ops_projects -config config.yaml -apply -yes
|
||||
```
|
||||
|
||||
### Права БД для пользователя приложения
|
||||
### Минимальные права БД для пользователя квотаций
|
||||
|
||||
#### Полный набор прав для обычного пользователя
|
||||
|
||||
Чтобы выдать существующему пользователю все необходимые права (без переоздания):
|
||||
Если нужен пользователь, который может работать с конфигурациями, но не может создавать/удалять прайслисты:
|
||||
|
||||
```sql
|
||||
-- Справочные таблицы (только чтение)
|
||||
GRANT SELECT ON RFQ_LOG.lot TO '<DB_USER>'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO '<DB_USER>'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_categories TO '<DB_USER>'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_pricelists TO '<DB_USER>'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO '<DB_USER>'@'%';
|
||||
-- 1) Создать (или оставить существующего) пользователя
|
||||
CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY 'StrongPassword!';
|
||||
|
||||
-- Таблицы конфигураций и проектов (чтение и запись)
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO '<DB_USER>'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO '<DB_USER>'@'%';
|
||||
-- 2) Сбросить лишние права (без пересоздания пользователя)
|
||||
REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'quote_user'@'%';
|
||||
|
||||
-- Таблицы синхронизации (только чтение для миграций, чтение+запись для статуса)
|
||||
GRANT SELECT ON RFQ_LOG.qt_client_local_migrations TO '<DB_USER>'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO '<DB_USER>'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO '<DB_USER>'@'%';
|
||||
|
||||
-- Применить изменения
|
||||
FLUSH PRIVILEGES;
|
||||
|
||||
-- Проверка выданных прав
|
||||
SHOW GRANTS FOR '<DB_USER>'@'%';
|
||||
```
|
||||
|
||||
#### Таблицы и их назначение
|
||||
|
||||
| Таблица | Назначение | Права | Примечание |
|
||||
|---------|-----------|-------|-----------|
|
||||
| `lot` | Справочник компонентов | SELECT | Существующая таблица |
|
||||
| `qt_lot_metadata` | Расширенные данные компонентов | SELECT | Метаданные компонентов |
|
||||
| `qt_categories` | Категории компонентов | SELECT | Справочник |
|
||||
| `qt_pricelists` | Прайслисты | SELECT | Управляется сервером |
|
||||
| `qt_pricelist_items` | Позиции прайслистов | SELECT | Управляется сервером |
|
||||
| `qt_configurations` | Сохранённые конфигурации | SELECT, INSERT, UPDATE | Основная таблица работы |
|
||||
| `qt_projects` | Проекты | SELECT, INSERT, UPDATE | Для группировки конфигураций |
|
||||
| `qt_client_local_migrations` | Справочник миграций БД | SELECT | Только чтение (управляется админом) |
|
||||
| `qt_client_schema_state` | Состояние локальной схемы | SELECT, INSERT, UPDATE | Отслеживание примененных миграций |
|
||||
| `qt_pricelist_sync_status` | Статус синхронизации | SELECT, INSERT, UPDATE | Отслеживание активности синхронизации |
|
||||
|
||||
#### При создании нового пользователя
|
||||
|
||||
Если нужно создать нового пользователя с нуля:
|
||||
|
||||
```sql
|
||||
-- 1) Создать пользователя
|
||||
CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY '<DB_PASSWORD>';
|
||||
|
||||
-- 2) Выдать все необходимые права
|
||||
-- 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'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_client_local_migrations TO 'quote_user'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO 'quote_user'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO 'quote_user'@'%';
|
||||
|
||||
-- 3) Применить изменения
|
||||
-- 4) Работа с конфигурациями
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
|
||||
|
||||
FLUSH PRIVILEGES;
|
||||
|
||||
-- 4) Проверить права
|
||||
SHOW GRANTS FOR 'quote_user'@'%';
|
||||
```
|
||||
|
||||
#### Важные замечания
|
||||
|
||||
- **Таблицы синхронизации** должны быть созданы администратором БД один раз. Приложение не требует прав CREATE TABLE.
|
||||
- **Прайслисты** (`qt_pricelists`, `qt_pricelist_items`) — справочные таблицы, управляются сервером, пользователь имеет только SELECT.
|
||||
- **Конфигурации и проекты** — таблицы, в которые пишет само приложение (INSERT, UPDATE при сохранении изменений).
|
||||
- **Таблицы миграций** нужны для синхронизации: приложение читает список миграций и отчитывается о применённых.
|
||||
- Если видите ошибку `Access denied for user ...@'<ip>'`, проверьте наличие конфликтующих записей пользователя с разными хостами (user@localhost vs user@'%').
|
||||
Важно:
|
||||
- не выдавайте `INSERT/UPDATE/DELETE` на `qt_pricelists` и `qt_pricelist_items`, если пользователь не должен управлять прайслистами;
|
||||
- если используется host-специфичный аккаунт (`'quote_user'@'192.168.x.x'`), назначьте права и для него;
|
||||
- после смены DB-настроек через `/setup` приложение перезапускается автоматически и подхватывает нового пользователя.
|
||||
|
||||
### 4. Импорт метаданных компонентов
|
||||
|
||||
@@ -214,7 +168,6 @@ make build-all # Сборка для всех платформ (Linux, mac
|
||||
make build-windows # Только для Windows
|
||||
make run # Запуск dev сервера
|
||||
make test # Запуск тестов
|
||||
make install-hooks # Установить git hooks (блокировка коммита с секретами)
|
||||
make clean # Очистка bin/
|
||||
make help # Показать все команды
|
||||
```
|
||||
@@ -232,56 +185,6 @@ make help # Показать все команды
|
||||
|
||||
Можно переопределить путь через `-localdb` или переменную окружения `QFS_DB_PATH`.
|
||||
|
||||
#### Sync readiness guard
|
||||
|
||||
Перед `push/pull` выполняется preflight-проверка:
|
||||
- доступен ли сервер (MariaDB);
|
||||
- можно ли проверить и применить централизованные миграции локальной БД;
|
||||
- подходит ли версия приложения под `min_app_version` миграций.
|
||||
|
||||
Если проверка не пройдена:
|
||||
- локальная работа (CRUD) продолжается;
|
||||
- sync API возвращает `423 Locked` с `reason_code` и `reason_text`;
|
||||
- в UI показывается красный индикатор и причина блокировки в модалке синхронизации.
|
||||
|
||||
#### Схема потоков данных синхронизации
|
||||
|
||||
```text
|
||||
[ SERVER / MariaDB ]
|
||||
┌───────────────────────────┐
|
||||
│ qt_projects │
|
||||
│ qt_configurations │
|
||||
│ qt_pricelists │
|
||||
│ qt_pricelist_items │
|
||||
│ qt_pricelist_sync_status │
|
||||
└─────────────┬─────────────┘
|
||||
│
|
||||
pull (projects/configs/pricelists)
|
||||
│
|
||||
┌──────────────────┴──────────────────┐
|
||||
│ │
|
||||
[ CLIENT A / local SQLite ] [ CLIENT B / local SQLite ]
|
||||
┌───────────────────────────────┐ ┌───────────────────────────────┐
|
||||
│ local_projects │ │ local_projects │
|
||||
│ local_configurations │ │ local_configurations │
|
||||
│ local_pricelists │ │ local_pricelists │
|
||||
│ local_pricelist_items │ │ local_pricelist_items │
|
||||
│ pending_changes (proj/config) │ │ pending_changes (proj/config) │
|
||||
└───────────────┬───────────────┘ └───────────────┬───────────────┘
|
||||
│ │
|
||||
push (projects/configurations only) push (projects/configurations only)
|
||||
│ │
|
||||
└──────────────────┬────────────────────┘
|
||||
│
|
||||
[ SERVER / MariaDB ]
|
||||
```
|
||||
|
||||
По сущностям:
|
||||
- Конфигурации: `Client <-> Server <-> Other Clients`
|
||||
- Проекты: `Client <-> Server <-> Other Clients`
|
||||
- Прайслисты: `Server -> Clients only` (локальный push отсутствует)
|
||||
- Локальная очистка прайслистов на клиенте: удаляются записи, которых нет на сервере и которые не используются активными локальными конфигурациями
|
||||
|
||||
### Версионность конфигураций (local-first)
|
||||
|
||||
Для `local_configurations` используется append-only versioning через полные snapshot-версии:
|
||||
@@ -315,7 +218,6 @@ POST /api/configs/:uuid/rollback
|
||||
### Локальный config.yaml
|
||||
|
||||
По умолчанию `qfs` ищет `config.yaml` в той же user-state папке, где лежит `qfs.db` (а не рядом с бинарником).
|
||||
Если файла нет, он создаётся автоматически. Если формат устарел, он автоматически мигрируется в runtime-формат (`server` + `logging`).
|
||||
Можно переопределить путь через `-config` или `QFS_CONFIG_PATH`.
|
||||
|
||||
## Docker
|
||||
@@ -346,23 +248,12 @@ quoteforge/
|
||||
│ ├── templates/ # HTML шаблоны
|
||||
│ └── static/ # CSS, JS, изображения
|
||||
├── migrations/ # SQL миграции
|
||||
├── config.example.yaml # Пример конфигурации
|
||||
├── releases/
|
||||
│ └── memory/ # Changelog между тегами (v1.2.1.md, v1.2.2.md, ...)
|
||||
├── config.yaml # Конфигурация
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
└── go.mod
|
||||
```
|
||||
|
||||
## Releases & Changelog
|
||||
|
||||
Change log между версиями хранится в `releases/memory/` каталоге в файлах вида `v{major}.{minor}.{patch}.md`.
|
||||
|
||||
Каждый файл содержит:
|
||||
- Список коммитов между версиями
|
||||
- Описание изменений и их влияния
|
||||
- Breaking changes и заметки о миграции
|
||||
|
||||
**Перед работой над кодом проверьте последний файл в этой папке, чтобы понять текущее состояние проекта.**
|
||||
|
||||
## Роли пользователей
|
||||
|
||||
| Роль | Описание |
|
||||
@@ -388,26 +279,8 @@ GET /api/configs/:uuid/versions # Список версий конф
|
||||
GET /api/configs/:uuid/versions/:version # Получить конкретную версию
|
||||
POST /api/configs/:uuid/rollback # Rollback на указанную версию
|
||||
POST /api/configs/:uuid/reactivate # Вернуть архивную конфигурацию в активные
|
||||
GET /api/sync/readiness # Статус readiness guard (ready|blocked|unknown)
|
||||
GET /api/sync/status # Сводный статус синхронизации
|
||||
GET /api/sync/info # Данные для модалки синхронизации
|
||||
POST /api/sync/push # Push pending changes (423, если blocked)
|
||||
POST /api/sync/all # Full sync push+pull (423, если blocked)
|
||||
POST /api/sync/components # Pull components (423, если blocked)
|
||||
POST /api/sync/pricelists # Pull pricelists (423, если blocked)
|
||||
```
|
||||
|
||||
### Краткая карта sync API
|
||||
|
||||
| Endpoint | Назначение | Поток |
|
||||
|----------|------------|-------|
|
||||
| `POST /api/sync/push` | Отправить локальные pending-изменения | `SQLite -> MariaDB` |
|
||||
| `POST /api/sync/components` | Подтянуть справочник компонентов | `MariaDB -> SQLite` |
|
||||
| `POST /api/sync/pricelists` | Подтянуть прайслисты и позиции | `MariaDB -> SQLite` |
|
||||
| `POST /api/sync/all` | Полный цикл: push + pull + импорт проектов/конфигураций | `двунаправленно` |
|
||||
| `GET /api/sync/readiness` | Статус preflight/readiness | `read-only` |
|
||||
| `GET /api/sync/status` / `GET /api/sync/info` | Сводка статуса и данных синхронизации | `read-only` |
|
||||
|
||||
#### Sync payload для versioning
|
||||
|
||||
События в `pending_changes` для конфигураций содержат:
|
||||
@@ -419,6 +292,50 @@ POST /api/sync/pricelists # Pull pricelists (423, если bloc
|
||||
|
||||
Это позволяет 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
|
||||
|
||||
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"
|
||||
|
||||
"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"
|
||||
"gorm.io/driver/mysql"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("config", "config.yaml", "path to config file")
|
||||
defaultLocalDBPath, err := appstate.ResolveDBPath("")
|
||||
if err != nil {
|
||||
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("========================================")
|
||||
|
||||
// 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
|
||||
log.Printf("Opening local SQLite at %s...", *localDBPath)
|
||||
local, err := localdb.New(*localDBPath)
|
||||
@@ -33,28 +51,6 @@ func main() {
|
||||
log.Fatalf("Failed to initialize local database: %v", err)
|
||||
}
|
||||
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
|
||||
var serverCount int64
|
||||
@@ -153,7 +149,23 @@ func main() {
|
||||
log.Printf(" Skipped: %d", skipped)
|
||||
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 {
|
||||
|
||||
@@ -10,8 +10,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appstate"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/driver/mysql"
|
||||
@@ -39,29 +38,17 @@ type migrationAction struct {
|
||||
}
|
||||
|
||||
func main() {
|
||||
defaultLocalDBPath, err := appstate.ResolveDBPath("")
|
||||
if err != nil {
|
||||
log.Fatalf("failed to resolve default local SQLite path: %v", err)
|
||||
}
|
||||
localDBPath := flag.String("localdb", defaultLocalDBPath, "path to local SQLite database (default: user state dir or QFS_DB_PATH)")
|
||||
configPath := flag.String("config", "config.yaml", "path to config file")
|
||||
apply := flag.Bool("apply", false, "apply migration (default is preview only)")
|
||||
yes := flag.Bool("yes", false, "skip interactive confirmation (works only with -apply)")
|
||||
flag.Parse()
|
||||
|
||||
local, err := localdb.New(*localDBPath)
|
||||
cfg, err := config.Load(*configPath)
|
||||
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),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -72,7 +59,7 @@ func main() {
|
||||
log.Fatalf("precheck failed: %v", err)
|
||||
}
|
||||
|
||||
actions, existingProjects, err := buildPlan(db, dbUser)
|
||||
actions, existingProjects, err := buildPlan(db, cfg.Database.User)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to build migration plan: %v", err)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
498
cmd/qfs/main.go
498
cmd/qfs/main.go
@@ -1,24 +1,20 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
syncpkg "sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -33,9 +29,11 @@ import (
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services/alerts"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gopkg.in/yaml.v3"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
@@ -44,9 +42,6 @@ import (
|
||||
// Version is set via ldflags during build
|
||||
var Version = "dev"
|
||||
|
||||
const backgroundSyncInterval = 5 * time.Minute
|
||||
const onDemandPullCooldown = 30 * time.Second
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("config", "", "path to config file (default: user state dir or QFS_CONFIG_PATH)")
|
||||
localDBPath := flag.String("localdb", "", "path to local SQLite database (default: user state dir or QFS_DB_PATH)")
|
||||
@@ -64,15 +59,15 @@ func main() {
|
||||
slog.Info("starting qfs", "version", Version, "executable", exePath)
|
||||
appmeta.SetVersion(Version)
|
||||
|
||||
resolvedLocalDBPath, err := appstate.ResolveDBPath(*localDBPath)
|
||||
resolvedConfigPath, err := appstate.ResolveConfigPath(*configPath)
|
||||
if err != nil {
|
||||
slog.Error("failed to resolve local database path", "error", err)
|
||||
slog.Error("failed to resolve config path", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
resolvedConfigPath, err := appstate.ResolveConfigPathNearDB(*configPath, resolvedLocalDBPath)
|
||||
resolvedLocalDBPath, err := appstate.ResolveDBPath(*localDBPath)
|
||||
if err != nil {
|
||||
slog.Error("failed to resolve config path", "error", err)
|
||||
slog.Error("failed to resolve local database path", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -115,10 +110,6 @@ func main() {
|
||||
}
|
||||
|
||||
// Load config for server settings (optional)
|
||||
if err := ensureDefaultConfigFile(resolvedConfigPath); err != nil {
|
||||
slog.Error("failed to ensure default config file", "path", resolvedConfigPath, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
cfg, err := config.Load(resolvedConfigPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
@@ -131,10 +122,6 @@ func main() {
|
||||
}
|
||||
}
|
||||
setConfigDefaults(cfg)
|
||||
if err := migrateConfigFileToRuntimeShape(resolvedConfigPath, cfg); err != nil {
|
||||
slog.Error("failed to migrate config file format", "path", resolvedConfigPath, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
slog.Info("resolved runtime files", "config_path", resolvedConfigPath, "localdb_path", resolvedLocalDBPath)
|
||||
|
||||
setupLogger(cfg.Logging)
|
||||
@@ -216,20 +203,11 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if readiness, readinessErr := syncService.GetReadiness(); readinessErr != nil {
|
||||
slog.Warn("sync readiness check failed on startup", "error", readinessErr)
|
||||
} else if readiness != nil && readiness.Blocked {
|
||||
slog.Warn("sync readiness blocked on startup",
|
||||
"reason_code", readiness.ReasonCode,
|
||||
"reason_text", readiness.ReasonText,
|
||||
)
|
||||
}
|
||||
|
||||
// Start background sync worker (will auto-skip when offline)
|
||||
workerCtx, workerCancel := context.WithCancel(context.Background())
|
||||
defer workerCancel()
|
||||
|
||||
syncWorker := sync.NewWorker(syncService, connMgr, backgroundSyncInterval)
|
||||
syncWorker := sync.NewWorker(syncService, connMgr, 5*time.Minute)
|
||||
go syncWorker.Start(workerCtx)
|
||||
|
||||
srv := &http.Server{
|
||||
@@ -326,96 +304,6 @@ func setConfigDefaults(cfg *config.Config) {
|
||||
}
|
||||
}
|
||||
|
||||
func ensureDefaultConfigFile(configPath string) error {
|
||||
if strings.TrimSpace(configPath) == "" {
|
||||
return fmt.Errorf("config path is empty")
|
||||
}
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
const defaultConfigYAML = `server:
|
||||
host: "127.0.0.1"
|
||||
port: 8080
|
||||
mode: "release"
|
||||
read_timeout: 30s
|
||||
write_timeout: 30s
|
||||
|
||||
logging:
|
||||
level: "info"
|
||||
format: "json"
|
||||
output: "stdout"
|
||||
`
|
||||
if err := os.WriteFile(configPath, []byte(defaultConfigYAML), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("created default config file", "path", configPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
type runtimeServerConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
Mode string `yaml:"mode"`
|
||||
ReadTimeout time.Duration `yaml:"read_timeout"`
|
||||
WriteTimeout time.Duration `yaml:"write_timeout"`
|
||||
}
|
||||
|
||||
type runtimeLoggingConfig struct {
|
||||
Level string `yaml:"level"`
|
||||
Format string `yaml:"format"`
|
||||
Output string `yaml:"output"`
|
||||
}
|
||||
|
||||
type runtimeConfigFile struct {
|
||||
Server runtimeServerConfig `yaml:"server"`
|
||||
Logging runtimeLoggingConfig `yaml:"logging"`
|
||||
}
|
||||
|
||||
// migrateConfigFileToRuntimeShape rewrites config.yaml in a minimal runtime format.
|
||||
// Deprecated sections from legacy configs are intentionally dropped.
|
||||
func migrateConfigFileToRuntimeShape(configPath string, cfg *config.Config) error {
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("config is nil")
|
||||
}
|
||||
|
||||
runtimeCfg := runtimeConfigFile{
|
||||
Server: runtimeServerConfig{
|
||||
Host: cfg.Server.Host,
|
||||
Port: cfg.Server.Port,
|
||||
Mode: cfg.Server.Mode,
|
||||
ReadTimeout: cfg.Server.ReadTimeout,
|
||||
WriteTimeout: cfg.Server.WriteTimeout,
|
||||
},
|
||||
Logging: runtimeLoggingConfig{
|
||||
Level: cfg.Logging.Level,
|
||||
Format: cfg.Logging.Format,
|
||||
Output: cfg.Logging.Output,
|
||||
},
|
||||
}
|
||||
|
||||
rendered, err := yaml.Marshal(&runtimeCfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal runtime config: %w", err)
|
||||
}
|
||||
|
||||
current, err := os.ReadFile(configPath)
|
||||
if err == nil && bytes.Equal(bytes.TrimSpace(current), bytes.TrimSpace(rendered)) {
|
||||
return nil
|
||||
}
|
||||
if err := os.WriteFile(configPath, rendered, 0644); err != nil {
|
||||
return fmt.Errorf("write runtime config: %w", err)
|
||||
}
|
||||
slog.Info("migrated config.yaml to runtime format", "path", configPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// runSetupMode starts a minimal server that only serves the setup page
|
||||
func runSetupMode(local *localdb.LocalDB) {
|
||||
restartSig := make(chan struct{}, 1)
|
||||
@@ -554,6 +442,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
// Repositories
|
||||
var componentRepo *repository.ComponentRepository
|
||||
var categoryRepo *repository.CategoryRepository
|
||||
var priceRepo *repository.PriceRepository
|
||||
var alertRepo *repository.AlertRepository
|
||||
var statsRepo *repository.StatsRepository
|
||||
var pricelistRepo *repository.PricelistRepository
|
||||
|
||||
@@ -561,6 +451,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
if mariaDB != nil {
|
||||
componentRepo = repository.NewComponentRepository(mariaDB)
|
||||
categoryRepo = repository.NewCategoryRepository(mariaDB)
|
||||
priceRepo = repository.NewPriceRepository(mariaDB)
|
||||
alertRepo = repository.NewAlertRepository(mariaDB)
|
||||
statsRepo = repository.NewStatsRepository(mariaDB)
|
||||
pricelistRepo = repository.NewPricelistRepository(mariaDB)
|
||||
} else {
|
||||
@@ -569,9 +461,12 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
}
|
||||
|
||||
// Services
|
||||
var pricingService *pricing.Service
|
||||
var componentService *services.ComponentService
|
||||
var quoteService *services.QuoteService
|
||||
var exportService *services.ExportService
|
||||
var alertService *alerts.Service
|
||||
var pricelistService *pricelist.Service
|
||||
var syncService *sync.Service
|
||||
var projectService *services.ProjectService
|
||||
|
||||
@@ -579,14 +474,20 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
syncService = sync.NewService(connMgr, local)
|
||||
|
||||
if mariaDB != nil {
|
||||
pricingService = pricing.NewService(componentRepo, priceRepo, cfg.Pricing)
|
||||
componentService = services.NewComponentService(componentRepo, categoryRepo, statsRepo)
|
||||
quoteService = services.NewQuoteService(componentRepo, statsRepo, pricelistRepo, local, nil)
|
||||
quoteService = services.NewQuoteService(componentRepo, statsRepo, pricingService)
|
||||
exportService = services.NewExportService(cfg.Export, categoryRepo)
|
||||
alertService = alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
|
||||
pricelistService = pricelist.NewService(mariaDB, pricelistRepo, componentRepo)
|
||||
} else {
|
||||
// In offline mode, we still need to create services that don't require DB.
|
||||
// In offline mode, we still need to create services that don't require DB
|
||||
pricingService = pricing.NewService(nil, nil, cfg.Pricing)
|
||||
componentService = services.NewComponentService(nil, nil, nil)
|
||||
quoteService = services.NewQuoteService(nil, nil, nil, local, nil)
|
||||
quoteService = services.NewQuoteService(nil, nil, pricingService)
|
||||
exportService = services.NewExportService(cfg.Export, nil)
|
||||
alertService = alerts.NewService(nil, nil, nil, nil, cfg.Alerts, cfg.Pricing)
|
||||
pricelistService = pricelist.NewService(nil, nil, nil)
|
||||
}
|
||||
|
||||
// isOnline function for local-first architecture
|
||||
@@ -618,75 +519,56 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
}
|
||||
}
|
||||
|
||||
type pullState struct {
|
||||
mu syncpkg.Mutex
|
||||
running bool
|
||||
lastStarted time.Time
|
||||
}
|
||||
triggerPull := func(label string, state *pullState, pullFn func() error) {
|
||||
state.mu.Lock()
|
||||
if state.running {
|
||||
state.mu.Unlock()
|
||||
syncProjectsFromServer := func() {
|
||||
if !connMgr.IsOnline() {
|
||||
return
|
||||
}
|
||||
if !state.lastStarted.IsZero() && time.Since(state.lastStarted) < onDemandPullCooldown {
|
||||
state.mu.Unlock()
|
||||
serverDB, err := connMgr.GetDB()
|
||||
if err != nil || serverDB == nil {
|
||||
return
|
||||
}
|
||||
state.running = true
|
||||
state.lastStarted = time.Now()
|
||||
state.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
state.mu.Lock()
|
||||
state.running = false
|
||||
state.mu.Unlock()
|
||||
}()
|
||||
if err := pullFn(); err != nil {
|
||||
slog.Warn("on-demand pull failed", "scope", label, "error", err)
|
||||
projectRepo := repository.NewProjectRepository(serverDB)
|
||||
serverProjects, _, err := projectRepo.List(0, 10000, true)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for i := range serverProjects {
|
||||
sp := serverProjects[i]
|
||||
localProject, getErr := local.GetProjectByUUID(sp.UUID)
|
||||
if getErr == nil && localProject != nil {
|
||||
// Keep unsynced local changes intact.
|
||||
if localProject.SyncStatus == "pending" {
|
||||
continue
|
||||
}
|
||||
localProject.OwnerUsername = sp.OwnerUsername
|
||||
localProject.Name = sp.Name
|
||||
localProject.IsActive = sp.IsActive
|
||||
localProject.IsSystem = sp.IsSystem
|
||||
localProject.CreatedAt = sp.CreatedAt
|
||||
localProject.UpdatedAt = sp.UpdatedAt
|
||||
serverID := sp.ID
|
||||
localProject.ServerID = &serverID
|
||||
localProject.SyncStatus = "synced"
|
||||
localProject.SyncedAt = &now
|
||||
_ = local.SaveProject(localProject)
|
||||
continue
|
||||
}
|
||||
}()
|
||||
|
||||
lp := localdb.ProjectToLocal(&sp)
|
||||
lp.SyncStatus = "synced"
|
||||
lp.SyncedAt = &now
|
||||
_ = local.SaveProject(lp)
|
||||
}
|
||||
}
|
||||
|
||||
var projectsPullState pullState
|
||||
var configsPullState pullState
|
||||
|
||||
syncProjectsFromServer := func() error {
|
||||
syncConfigurationsFromServer := func() {
|
||||
if !connMgr.IsOnline() {
|
||||
return nil
|
||||
return
|
||||
}
|
||||
if readiness, err := syncService.EnsureReadinessForSync(); err != nil {
|
||||
slog.Warn("skipping project pull: sync readiness blocked",
|
||||
"error", err,
|
||||
"reason_code", readiness.ReasonCode,
|
||||
"reason_text", readiness.ReasonText,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
if _, err := syncService.ImportProjectsToLocal(); err != nil && !errors.Is(err, sync.ErrOffline) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
syncConfigurationsFromServer := func() error {
|
||||
if !connMgr.IsOnline() {
|
||||
return nil
|
||||
}
|
||||
if readiness, err := syncService.EnsureReadinessForSync(); err != nil {
|
||||
slog.Warn("skipping configuration pull: sync readiness blocked",
|
||||
"error", err,
|
||||
"reason_code", readiness.ReasonCode,
|
||||
"reason_text", readiness.ReasonText,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
_, err := configService.ImportFromServer()
|
||||
if err != nil && !errors.Is(err, sync.ErrOffline) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
_, _ = configService.ImportFromServer()
|
||||
}
|
||||
|
||||
// Use filepath.Join for cross-platform path compatibility
|
||||
@@ -695,9 +577,10 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
// Handlers
|
||||
componentHandler := handlers.NewComponentHandler(componentService, local)
|
||||
quoteHandler := handlers.NewQuoteHandler(quoteService)
|
||||
exportHandler := handlers.NewExportHandler(exportService, configService, componentService, projectService)
|
||||
pricelistHandler := handlers.NewPricelistHandler(local)
|
||||
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval)
|
||||
exportHandler := handlers.NewExportHandler(exportService, configService, componentService)
|
||||
pricingHandler := handlers.NewPricingHandler(mariaDB, pricingService, alertService, componentRepo, priceRepo, statsRepo)
|
||||
pricelistHandler := handlers.NewPricelistHandler(pricelistService, local)
|
||||
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("creating sync handler: %w", err)
|
||||
}
|
||||
@@ -752,39 +635,28 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
// DB status endpoint
|
||||
router.GET("/api/db-status", func(c *gin.Context) {
|
||||
var lotCount, lotLogCount, metadataCount int64
|
||||
var dbOK bool
|
||||
var dbOK bool = false
|
||||
var dbError string
|
||||
includeCounts := c.Query("include_counts") == "true"
|
||||
|
||||
// Fast status path: do not execute heavy COUNT queries unless requested.
|
||||
// Check if connection exists (fast check, no reconnect attempt)
|
||||
status := connMgr.GetStatus()
|
||||
dbOK = status.IsConnected
|
||||
if !status.IsConnected {
|
||||
if status.IsConnected {
|
||||
// Already connected, safe to use
|
||||
if db, err := connMgr.GetDB(); err == nil && db != nil {
|
||||
dbOK = true
|
||||
db.Table("lot").Count(&lotCount)
|
||||
db.Table("lot_log").Count(&lotLogCount)
|
||||
db.Table("qt_lot_metadata").Count(&metadataCount)
|
||||
}
|
||||
} else {
|
||||
// Not connected - don't try to reconnect on status check
|
||||
// This prevents 3s timeout on every request
|
||||
dbError = "Database not connected (offline mode)"
|
||||
if status.LastError != "" {
|
||||
dbError = status.LastError
|
||||
}
|
||||
}
|
||||
|
||||
// Optional diagnostics mode with server table counts.
|
||||
if includeCounts && status.IsConnected {
|
||||
if db, err := connMgr.GetDB(); err == nil && db != nil {
|
||||
_ = db.Table("lot").Count(&lotCount)
|
||||
_ = db.Table("lot_log").Count(&lotLogCount)
|
||||
_ = db.Table("qt_lot_metadata").Count(&metadataCount)
|
||||
} else if err != nil {
|
||||
dbOK = false
|
||||
dbError = err.Error()
|
||||
} else {
|
||||
dbOK = false
|
||||
dbError = "Database not connected (offline mode)"
|
||||
}
|
||||
} else {
|
||||
lotCount = 0
|
||||
lotLogCount = 0
|
||||
metadataCount = 0
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"connected": dbOK,
|
||||
"error": dbError,
|
||||
@@ -815,8 +687,12 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
router.GET("/configurator", webHandler.Configurator)
|
||||
router.GET("/projects", webHandler.Projects)
|
||||
router.GET("/projects/:uuid", webHandler.ProjectDetail)
|
||||
router.GET("/pricelists", webHandler.Pricelists)
|
||||
router.GET("/pricelists", func(c *gin.Context) {
|
||||
// Redirect to admin/pricing with pricelists tab
|
||||
c.Redirect(http.StatusFound, "/admin/pricing?tab=pricelists")
|
||||
})
|
||||
router.GET("/pricelists/:id", webHandler.PricelistDetail)
|
||||
router.GET("/admin/pricing", webHandler.AdminPricing)
|
||||
|
||||
// htmx partials
|
||||
partials := router.Group("/partials")
|
||||
@@ -847,7 +723,6 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
{
|
||||
quote.POST("/validate", quoteHandler.Validate)
|
||||
quote.POST("/calculate", quoteHandler.Calculate)
|
||||
quote.POST("/price-levels", quoteHandler.PriceLevels)
|
||||
}
|
||||
|
||||
// Export (public)
|
||||
@@ -860,17 +735,19 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
pricelists := api.Group("/pricelists")
|
||||
{
|
||||
pricelists.GET("", pricelistHandler.List)
|
||||
pricelists.GET("/can-write", pricelistHandler.CanWrite)
|
||||
pricelists.GET("/latest", pricelistHandler.GetLatest)
|
||||
pricelists.GET("/:id", pricelistHandler.Get)
|
||||
pricelists.GET("/:id/items", pricelistHandler.GetItems)
|
||||
pricelists.GET("/:id/lots", pricelistHandler.GetLotNames)
|
||||
pricelists.POST("", pricelistHandler.Create)
|
||||
pricelists.DELETE("/:id", pricelistHandler.Delete)
|
||||
}
|
||||
|
||||
// Configurations (public - RBAC disabled)
|
||||
configs := api.Group("/configs")
|
||||
{
|
||||
configs.GET("", func(c *gin.Context) {
|
||||
triggerPull("configs", &configsPullState, syncConfigurationsFromServer)
|
||||
syncConfigurationsFromServer()
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
||||
@@ -926,23 +803,6 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
c.JSON(http.StatusCreated, config)
|
||||
})
|
||||
|
||||
configs.POST("/preview-article", func(c *gin.Context) {
|
||||
var req services.ArticlePreviewRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
result, err := configService.BuildArticlePreview(&req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"article": result.Article,
|
||||
"warnings": result.Warnings,
|
||||
})
|
||||
})
|
||||
|
||||
configs.GET("/:uuid", func(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
config, err := configService.GetByUUIDNoAuth(uuid)
|
||||
@@ -1169,45 +1029,20 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
"current_version": currentVersion,
|
||||
})
|
||||
})
|
||||
|
||||
configs.GET("/:uuid/export", exportHandler.ExportConfigCSV)
|
||||
}
|
||||
|
||||
projects := api.Group("/projects")
|
||||
{
|
||||
projects.GET("", func(c *gin.Context) {
|
||||
triggerPull("projects", &projectsPullState, syncProjectsFromServer)
|
||||
triggerPull("configs", &configsPullState, syncConfigurationsFromServer)
|
||||
syncProjectsFromServer()
|
||||
syncConfigurationsFromServer()
|
||||
|
||||
status := c.DefaultQuery("status", "active")
|
||||
search := strings.ToLower(strings.TrimSpace(c.Query("search")))
|
||||
author := strings.ToLower(strings.TrimSpace(c.Query("author")))
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
// Return all projects by default (set high limit for configs to reference)
|
||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "1000"))
|
||||
sortField := strings.ToLower(strings.TrimSpace(c.DefaultQuery("sort", "created_at")))
|
||||
sortDir := strings.ToLower(strings.TrimSpace(c.DefaultQuery("dir", "desc")))
|
||||
if status != "active" && status != "archived" && status != "all" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"})
|
||||
return
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 {
|
||||
perPage = 10
|
||||
}
|
||||
if perPage > 100 {
|
||||
perPage = 100
|
||||
}
|
||||
if sortField != "name" && sortField != "created_at" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sort field"})
|
||||
return
|
||||
}
|
||||
if sortDir != "asc" && sortDir != "desc" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sort direction"})
|
||||
return
|
||||
}
|
||||
|
||||
allProjects, err := projectService.ListByUser(dbUsername, true)
|
||||
if err != nil {
|
||||
@@ -1227,141 +1062,42 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
if search != "" && !strings.Contains(strings.ToLower(p.Name), search) {
|
||||
continue
|
||||
}
|
||||
if author != "" && !strings.Contains(strings.ToLower(strings.TrimSpace(p.OwnerUsername)), author) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, p)
|
||||
}
|
||||
|
||||
sort.Slice(filtered, func(i, j int) bool {
|
||||
left := filtered[i]
|
||||
right := filtered[j]
|
||||
if sortField == "name" {
|
||||
leftName := strings.ToLower(strings.TrimSpace(left.Name))
|
||||
rightName := strings.ToLower(strings.TrimSpace(right.Name))
|
||||
if leftName == rightName {
|
||||
if sortDir == "asc" {
|
||||
return left.CreatedAt.Before(right.CreatedAt)
|
||||
}
|
||||
return left.CreatedAt.After(right.CreatedAt)
|
||||
}
|
||||
if sortDir == "asc" {
|
||||
return leftName < rightName
|
||||
}
|
||||
return leftName > rightName
|
||||
}
|
||||
if left.CreatedAt.Equal(right.CreatedAt) {
|
||||
leftName := strings.ToLower(strings.TrimSpace(left.Name))
|
||||
rightName := strings.ToLower(strings.TrimSpace(right.Name))
|
||||
if sortDir == "asc" {
|
||||
return leftName < rightName
|
||||
}
|
||||
return leftName > rightName
|
||||
}
|
||||
if sortDir == "asc" {
|
||||
return left.CreatedAt.Before(right.CreatedAt)
|
||||
}
|
||||
return left.CreatedAt.After(right.CreatedAt)
|
||||
})
|
||||
|
||||
total := len(filtered)
|
||||
totalPages := 0
|
||||
if total > 0 {
|
||||
totalPages = int(math.Ceil(float64(total) / float64(perPage)))
|
||||
}
|
||||
if totalPages > 0 && page > totalPages {
|
||||
page = totalPages
|
||||
}
|
||||
|
||||
start := (page - 1) * perPage
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := start + perPage
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
|
||||
paged := []models.Project{}
|
||||
if start < total {
|
||||
paged = filtered[start:end]
|
||||
}
|
||||
|
||||
// Build per-project active config stats in one pass (avoid N+1 scans).
|
||||
projectConfigCount := map[string]int{}
|
||||
projectConfigTotal := map[string]float64{}
|
||||
if localConfigs, cfgErr := local.GetConfigurations(); cfgErr == nil {
|
||||
for i := range localConfigs {
|
||||
cfg := localConfigs[i]
|
||||
if !cfg.IsActive || cfg.ProjectUUID == nil || *cfg.ProjectUUID == "" {
|
||||
continue
|
||||
}
|
||||
projectUUID := *cfg.ProjectUUID
|
||||
projectConfigCount[projectUUID]++
|
||||
if cfg.TotalPrice != nil {
|
||||
projectConfigTotal[projectUUID] += *cfg.TotalPrice
|
||||
projectRows := make([]gin.H, 0, len(filtered))
|
||||
for i := range filtered {
|
||||
p := filtered[i]
|
||||
configs, err := projectService.ListConfigurations(p.UUID, dbUsername, "active")
|
||||
if err != nil {
|
||||
configs = &services.ProjectConfigurationsResult{
|
||||
ProjectUUID: p.UUID,
|
||||
Configs: []models.Configuration{},
|
||||
Total: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
projectRows := make([]gin.H, 0, len(paged))
|
||||
for i := range paged {
|
||||
p := paged[i]
|
||||
projectRows = append(projectRows, gin.H{
|
||||
"id": p.ID,
|
||||
"uuid": p.UUID,
|
||||
"owner_username": p.OwnerUsername,
|
||||
"name": p.Name,
|
||||
"tracker_url": p.TrackerURL,
|
||||
"is_active": p.IsActive,
|
||||
"is_system": p.IsSystem,
|
||||
"created_at": p.CreatedAt,
|
||||
"updated_at": p.UpdatedAt,
|
||||
"config_count": projectConfigCount[p.UUID],
|
||||
"total": projectConfigTotal[p.UUID],
|
||||
"config_count": len(configs.Configs),
|
||||
"total": configs.Total,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"projects": projectRows,
|
||||
"status": status,
|
||||
"search": search,
|
||||
"author": author,
|
||||
"sort": sortField,
|
||||
"dir": sortDir,
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
"total": total,
|
||||
"total_pages": totalPages,
|
||||
"projects": projectRows,
|
||||
"status": status,
|
||||
"search": search,
|
||||
"total": len(projectRows),
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
// GET /api/projects/all - Returns all projects without pagination for UI dropdowns
|
||||
projects.GET("/all", func(c *gin.Context) {
|
||||
allProjects, err := projectService.ListByUser(dbUsername, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Return simplified list of all projects (UUID + Name only)
|
||||
type ProjectSimple struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
simplified := make([]ProjectSimple, 0, len(allProjects))
|
||||
for _, p := range allProjects {
|
||||
simplified = append(simplified, ProjectSimple{
|
||||
UUID: p.UUID,
|
||||
Name: p.Name,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, simplified)
|
||||
})
|
||||
|
||||
projects.POST("", func(c *gin.Context) {
|
||||
var req services.CreateProjectRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -1452,7 +1188,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
})
|
||||
|
||||
projects.GET("/:uuid/configs", func(c *gin.Context) {
|
||||
triggerPull("configs", &configsPullState, syncConfigurationsFromServer)
|
||||
syncConfigurationsFromServer()
|
||||
|
||||
status := c.DefaultQuery("status", "active")
|
||||
if status != "active" && status != "archived" && status != "all" {
|
||||
@@ -1512,13 +1248,27 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
})
|
||||
}
|
||||
|
||||
// Pricing admin (public - RBAC disabled)
|
||||
pricingAdmin := api.Group("/admin/pricing")
|
||||
{
|
||||
pricingAdmin.GET("/stats", pricingHandler.GetStats)
|
||||
pricingAdmin.GET("/components", pricingHandler.ListComponents)
|
||||
pricingAdmin.GET("/components/:lot_name", pricingHandler.GetComponentPricing)
|
||||
pricingAdmin.POST("/update", pricingHandler.UpdatePrice)
|
||||
pricingAdmin.POST("/preview", pricingHandler.PreviewPrice)
|
||||
pricingAdmin.POST("/recalculate-all", pricingHandler.RecalculateAll)
|
||||
|
||||
pricingAdmin.GET("/alerts", pricingHandler.ListAlerts)
|
||||
pricingAdmin.POST("/alerts/:id/acknowledge", pricingHandler.AcknowledgeAlert)
|
||||
pricingAdmin.POST("/alerts/:id/resolve", pricingHandler.ResolveAlert)
|
||||
pricingAdmin.POST("/alerts/:id/ignore", pricingHandler.IgnoreAlert)
|
||||
}
|
||||
|
||||
// Sync API (for offline mode)
|
||||
syncAPI := api.Group("/sync")
|
||||
{
|
||||
syncAPI.GET("/status", syncHandler.GetStatus)
|
||||
syncAPI.GET("/readiness", syncHandler.GetReadiness)
|
||||
syncAPI.GET("/info", syncHandler.GetInfo)
|
||||
syncAPI.GET("/users-status", syncHandler.GetUsersStatus)
|
||||
syncAPI.POST("/components", syncHandler.SyncComponents)
|
||||
syncAPI.POST("/pricelists", syncHandler.SyncPricelists)
|
||||
syncAPI.POST("/all", syncHandler.SyncAll)
|
||||
|
||||
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.
@@ -6,7 +6,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -56,25 +55,6 @@ func ResolveConfigPath(explicitPath string) (string, error) {
|
||||
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)
|
||||
// to targetPath if targetPath does not already exist.
|
||||
// Returns source path if migration happened.
|
||||
|
||||
@@ -1,106 +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
|
||||
}
|
||||
for _, lot := range lotNames {
|
||||
cat := strings.TrimSpace(cats[lot])
|
||||
if cat == "" {
|
||||
return nil, &MissingCategoryForLotError{LotName: lot}
|
||||
}
|
||||
cats[lot] = cat
|
||||
}
|
||||
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,56 +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 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 (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -42,18 +39,8 @@ type DatabaseConfig struct {
|
||||
}
|
||||
|
||||
func (d *DatabaseConfig) DSN() string {
|
||||
cfg := mysqlDriver.NewConfig()
|
||||
cfg.User = d.User
|
||||
cfg.Passwd = d.Password
|
||||
cfg.Net = "tcp"
|
||||
cfg.Addr = net.JoinHostPort(d.Host, strconv.Itoa(d.Port))
|
||||
cfg.DBName = d.Name
|
||||
cfg.ParseTime = true
|
||||
cfg.Loc = time.Local
|
||||
cfg.Params = map[string]string{
|
||||
"charset": "utf8mb4",
|
||||
}
|
||||
return cfg.FormatDSN()
|
||||
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||
d.User, d.Password, d.Host, d.Port, d.Name)
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
|
||||
@@ -3,10 +3,8 @@ package handlers
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"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/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -27,12 +25,6 @@ func NewComponentHandler(componentService *services.ComponentService, localDB *l
|
||||
func (h *ComponentHandler) List(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 {
|
||||
perPage = 20
|
||||
}
|
||||
|
||||
filter := repository.ComponentFilter{
|
||||
Category: c.Query("category"),
|
||||
@@ -41,68 +33,73 @@ func (h *ComponentHandler) List(c *gin.Context) {
|
||||
ExcludeHidden: c.Query("include_hidden") != "true", // По умолчанию скрытые не показываются
|
||||
}
|
||||
|
||||
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)
|
||||
result, err := h.componentService.List(filter, page, perPage)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
components := make([]services.ComponentView, len(localComps))
|
||||
for i, lc := range localComps {
|
||||
components[i] = services.ComponentView{
|
||||
LotName: lc.LotName,
|
||||
Description: lc.LotDescription,
|
||||
Category: lc.Category,
|
||||
CategoryName: lc.Category,
|
||||
Model: lc.Model,
|
||||
// If offline mode (empty result), fallback to local components
|
||||
isOffline := false
|
||||
if v, ok := c.Get("is_offline"); ok {
|
||||
if b, ok := v.(bool); ok {
|
||||
isOffline = b
|
||||
}
|
||||
}
|
||||
if isOffline && result.Total == 0 && h.localDB != nil {
|
||||
localFilter := localdb.ComponentFilter{
|
||||
Category: filter.Category,
|
||||
Search: filter.Search,
|
||||
HasPrice: filter.HasPrice,
|
||||
}
|
||||
|
||||
offset := (page - 1) * perPage
|
||||
localComps, total, err := h.localDB.ListComponents(localFilter, offset, perPage)
|
||||
if err == nil && len(localComps) > 0 {
|
||||
// Convert local components to ComponentView format
|
||||
components := make([]services.ComponentView, len(localComps))
|
||||
for i, lc := range localComps {
|
||||
components[i] = services.ComponentView{
|
||||
LotName: lc.LotName,
|
||||
Description: lc.LotDescription,
|
||||
Category: lc.Category,
|
||||
CategoryName: lc.Category, // No translation in local mode
|
||||
Model: lc.Model,
|
||||
CurrentPrice: lc.CurrentPrice,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, &services.ComponentListResult{
|
||||
Components: components,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, &services.ComponentListResult{
|
||||
Components: components,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
})
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *ComponentHandler) Get(c *gin.Context) {
|
||||
lotName := c.Param("lot_name")
|
||||
component, err := h.localDB.GetLocalComponent(lotName)
|
||||
|
||||
component, err := h.componentService.GetByLotName(lotName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "component not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, services.ComponentView{
|
||||
LotName: component.LotName,
|
||||
Description: component.LotDescription,
|
||||
Category: component.Category,
|
||||
CategoryName: component.Category,
|
||||
Model: component.Model,
|
||||
})
|
||||
c.JSON(http.StatusOK, component)
|
||||
}
|
||||
|
||||
func (h *ComponentHandler) GetCategories(c *gin.Context) {
|
||||
codes, err := h.localDB.GetLocalComponentCategories()
|
||||
if err == nil && len(codes) > 0 {
|
||||
categories := make([]models.Category, 0, len(codes))
|
||||
for _, code := range codes {
|
||||
trimmed := strings.TrimSpace(code)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
categories = append(categories, models.Category{Code: trimmed, Name: trimmed})
|
||||
}
|
||||
c.JSON(http.StatusOK, categories)
|
||||
categories, err := h.componentService.GetCategories()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.DefaultCategories)
|
||||
c.JSON(http.StatusOK, categories)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package handlers
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/middleware"
|
||||
@@ -15,29 +14,23 @@ type ExportHandler struct {
|
||||
exportService *services.ExportService
|
||||
configService services.ConfigurationGetter
|
||||
componentService *services.ComponentService
|
||||
projectService *services.ProjectService
|
||||
}
|
||||
|
||||
func NewExportHandler(
|
||||
exportService *services.ExportService,
|
||||
configService services.ConfigurationGetter,
|
||||
componentService *services.ComponentService,
|
||||
projectService *services.ProjectService,
|
||||
) *ExportHandler {
|
||||
return &ExportHandler{
|
||||
exportService: exportService,
|
||||
configService: configService,
|
||||
componentService: componentService,
|
||||
projectService: projectService,
|
||||
}
|
||||
}
|
||||
|
||||
type ExportRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
ProjectName string `json:"project_name"`
|
||||
ProjectUUID string `json:"project_uuid"`
|
||||
Article string `json:"article"`
|
||||
Items []struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Items []struct {
|
||||
LotName string `json:"lot_name" binding:"required"`
|
||||
Quantity int `json:"quantity" binding:"required,min=1"`
|
||||
UnitPrice float64 `json:"unit_price"`
|
||||
@@ -54,40 +47,15 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
||||
|
||||
data := h.buildExportData(&req)
|
||||
|
||||
// Validate before streaming (can return JSON error)
|
||||
if len(data.Items) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"})
|
||||
csvData, err := h.exportService.ToCSV(data)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get project name if available
|
||||
projectName := req.ProjectName
|
||||
if projectName == "" && req.ProjectUUID != "" {
|
||||
// Try to load project name from database
|
||||
username := middleware.GetUsername(c)
|
||||
if project, err := h.projectService.GetByUUID(req.ProjectUUID, username); err == nil && project != nil {
|
||||
projectName = project.Name
|
||||
}
|
||||
}
|
||||
if projectName == "" {
|
||||
projectName = 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"), projectName, req.Name, articleSegment)
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
filename := fmt.Sprintf("%s %s SPEC.csv", time.Now().Format("2006-01-02"), req.Name)
|
||||
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
|
||||
}
|
||||
c.Data(http.StatusOK, "text/csv; charset=utf-8", csvData)
|
||||
}
|
||||
|
||||
func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData {
|
||||
@@ -122,7 +90,6 @@ func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData
|
||||
|
||||
return &services.ExportData{
|
||||
Name: req.Name,
|
||||
Article: req.Article,
|
||||
Items: items,
|
||||
Total: total,
|
||||
Notes: req.Notes,
|
||||
@@ -130,29 +97,10 @@ func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
username := middleware.GetUsername(c)
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
// Get config before streaming (can return JSON error)
|
||||
config, err := h.configService.GetByUUID(uuid, username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
@@ -161,33 +109,13 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
|
||||
|
||||
data := h.exportService.ConfigToExportData(config, h.componentService)
|
||||
|
||||
// Validate before streaming (can return JSON error)
|
||||
if len(data.Items) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"})
|
||||
csvData, err := h.exportService.ToCSV(data)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get project name if configuration belongs to a project
|
||||
projectName := 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 {
|
||||
projectName = project.Name
|
||||
}
|
||||
}
|
||||
|
||||
// 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"), projectName, config.Name)
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
filename := fmt.Sprintf("%s %s SPEC.csv", config.CreatedAt.Format("2006-01-02"), config.Name)
|
||||
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
|
||||
}
|
||||
c.Data(http.StatusOK, "text/csv; charset=utf-8", csvData)
|
||||
}
|
||||
|
||||
@@ -1,314 +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 a basic mock component service that doesn't panic
|
||||
mockComponentService := &services.ComponentService{}
|
||||
|
||||
// Create handler with mocks
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
||||
handler := NewExportHandler(
|
||||
exportSvc,
|
||||
&mockConfigService{},
|
||||
mockComponentService,
|
||||
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.Compare(actualBOM, expectedBOM) != 0 {
|
||||
t.Errorf("UTF-8 BOM mismatch. Expected %v, got %v", expectedBOM, actualBOM)
|
||||
}
|
||||
|
||||
// Check semicolon delimiter in CSV
|
||||
reader := csv.NewReader(bytes.NewReader(responseBody[3:]))
|
||||
reader.Comma = ';'
|
||||
|
||||
header, err := reader.Read()
|
||||
if err != nil {
|
||||
t.Errorf("Failed to parse CSV header: %v", err)
|
||||
}
|
||||
|
||||
if len(header) != 6 {
|
||||
t.Errorf("Expected 6 columns, got %d", len(header))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportCSV_InvalidRequest(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
||||
handler := NewExportHandler(
|
||||
exportSvc,
|
||||
&mockConfigService{},
|
||||
&services.ComponentService{},
|
||||
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)
|
||||
handler := NewExportHandler(
|
||||
exportSvc,
|
||||
&mockConfigService{},
|
||||
&services.ComponentService{},
|
||||
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)
|
||||
handler := NewExportHandler(
|
||||
exportSvc,
|
||||
&mockConfigService{config: mockConfig},
|
||||
&services.ComponentService{},
|
||||
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.Compare(actualBOM, expectedBOM) != 0 {
|
||||
t.Errorf("UTF-8 BOM mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportConfigCSV_NotFound(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
||||
handler := NewExportHandler(
|
||||
exportSvc,
|
||||
&mockConfigService{err: errors.New("config not found")},
|
||||
&services.ComponentService{},
|
||||
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)
|
||||
handler := NewExportHandler(
|
||||
exportSvc,
|
||||
&mockConfigService{config: mockConfig},
|
||||
&services.ComponentService{},
|
||||
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,93 +2,72 @@ package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
|
||||
)
|
||||
|
||||
type PricelistHandler struct {
|
||||
service *pricelist.Service
|
||||
localDB *localdb.LocalDB
|
||||
}
|
||||
|
||||
func NewPricelistHandler(localDB *localdb.LocalDB) *PricelistHandler {
|
||||
return &PricelistHandler{localDB: localDB}
|
||||
func NewPricelistHandler(service *pricelist.Service, localDB *localdb.LocalDB) *PricelistHandler {
|
||||
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) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
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 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if source != "" {
|
||||
filtered := localPLs[:0]
|
||||
for _, lpl := range localPLs {
|
||||
if strings.EqualFold(lpl.Source, source) {
|
||||
filtered = append(filtered, lpl)
|
||||
|
||||
// If offline (empty list), fallback to local pricelists
|
||||
if total == 0 && h.localDB != nil {
|
||||
localPLs, err := h.localDB.GetLocalPricelists()
|
||||
if err == nil && len(localPLs) > 0 {
|
||||
// Convert to PricelistSummary format
|
||||
summaries := make([]map[string]interface{}, len(localPLs))
|
||||
for i, lpl := range localPLs {
|
||||
summaries[i] = map[string]interface{}{
|
||||
"id": lpl.ServerID,
|
||||
"version": lpl.Version,
|
||||
"created_by": "sync",
|
||||
"item_count": 0, // Not tracked
|
||||
"usage_count": 0, // Not tracked in local
|
||||
"is_active": true,
|
||||
"created_at": lpl.CreatedAt,
|
||||
"synced_from": "local",
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"pricelists": summaries,
|
||||
"total": len(summaries),
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
"offline": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
localPLs = filtered
|
||||
}
|
||||
if activeOnly {
|
||||
// Local cache stores only active snapshots for normal operations.
|
||||
}
|
||||
sort.SliceStable(localPLs, func(i, j int) bool { return localPLs[i].CreatedAt.After(localPLs[j].CreatedAt) })
|
||||
total := len(localPLs)
|
||||
start := (page - 1) * perPage
|
||||
if start > total {
|
||||
start = total
|
||||
}
|
||||
end := start + perPage
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
pageSlice := localPLs[start:end]
|
||||
summaries := make([]map[string]interface{}, 0, len(pageSlice))
|
||||
for _, lpl := range pageSlice {
|
||||
itemCount := h.localDB.CountLocalPricelistItems(lpl.ID)
|
||||
usageCount := 0
|
||||
if lpl.IsUsed {
|
||||
usageCount = 1
|
||||
}
|
||||
summaries = append(summaries, map[string]interface{}{
|
||||
"id": lpl.ServerID,
|
||||
"source": lpl.Source,
|
||||
"version": lpl.Version,
|
||||
"created_by": "sync",
|
||||
"item_count": itemCount,
|
||||
"usage_count": usageCount,
|
||||
"is_active": true,
|
||||
"created_at": lpl.CreatedAt,
|
||||
"synced_from": "local",
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"pricelists": summaries,
|
||||
"pricelists": pricelists,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
})
|
||||
}
|
||||
|
||||
// Get returns a single pricelist by ID.
|
||||
// Get returns a single pricelist by ID
|
||||
func (h *PricelistHandler) Get(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
@@ -97,25 +76,68 @@ func (h *PricelistHandler) Get(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
localPL, err := h.localDB.GetLocalPricelistByServerID(uint(id))
|
||||
pl, err := h.service.GetByID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "pricelist not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": localPL.ServerID,
|
||||
"source": localPL.Source,
|
||||
"version": localPL.Version,
|
||||
"created_by": "sync",
|
||||
"item_count": h.localDB.CountLocalPricelistItems(localPL.ID),
|
||||
"is_active": true,
|
||||
"created_at": localPL.CreatedAt,
|
||||
"synced_from": "local",
|
||||
})
|
||||
c.JSON(http.StatusOK, pl)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
@@ -128,102 +150,57 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
|
||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50"))
|
||||
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 {
|
||||
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()})
|
||||
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{
|
||||
"source": localPL.Source,
|
||||
"items": resultItems,
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *PricelistHandler) GetLotNames(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
|
||||
return
|
||||
}
|
||||
|
||||
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),
|
||||
})
|
||||
// CanWrite returns whether the current user can create pricelists
|
||||
func (h *PricelistHandler) CanWrite(c *gin.Context) {
|
||||
canWrite, debugInfo := h.service.CanWriteDebug()
|
||||
c.JSON(http.StatusOK, gin.H{"can_write": canWrite, "debug": debugInfo})
|
||||
}
|
||||
|
||||
// GetLatest returns the most recent active pricelist.
|
||||
// GetLatest returns the most recent active pricelist
|
||||
func (h *PricelistHandler) GetLatest(c *gin.Context) {
|
||||
source := c.DefaultQuery("source", string(models.PricelistSourceEstimate))
|
||||
source = string(models.NormalizePricelistSource(source))
|
||||
|
||||
localPL, err := h.localDB.GetLatestLocalPricelistBySource(source)
|
||||
// Try to get from server first
|
||||
pl, err := h.service.GetLatestActive()
|
||||
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
|
||||
}
|
||||
localPL, localErr := h.localDB.GetLatestLocalPricelist()
|
||||
if localErr != nil {
|
||||
// No local pricelists either
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "no pricelists available",
|
||||
"local_error": localErr.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
// Return local pricelist
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": localPL.ServerID,
|
||||
"version": localPL.Version,
|
||||
"created_by": "sync",
|
||||
"item_count": 0, // Not tracked in local pricelists
|
||||
"is_active": true,
|
||||
"created_at": localPL.CreatedAt,
|
||||
"synced_from": "local",
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": localPL.ServerID,
|
||||
"source": localPL.Source,
|
||||
"version": localPL.Version,
|
||||
"created_by": "sync",
|
||||
"item_count": h.localDB.CountLocalPricelistItems(localPL.ID),
|
||||
"is_active": true,
|
||||
"created_at": localPL.CreatedAt,
|
||||
"synced_from": "local",
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, pl)
|
||||
}
|
||||
|
||||
@@ -1,84 +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)
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
"net/http"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
)
|
||||
|
||||
type QuoteHandler struct {
|
||||
@@ -49,19 +49,3 @@ func (h *QuoteHandler) Calculate(c *gin.Context) {
|
||||
"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,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -14,9 +13,8 @@ import (
|
||||
qfassets "git.mchus.pro/mchus/quoteforge"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/db"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||
"github.com/gin-gonic/gin"
|
||||
gormmysql "gorm.io/driver/mysql"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
@@ -95,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),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -170,9 +169,10 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 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),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -254,19 +254,3 @@ func testWritePermission(db *gorm.DB) bool {
|
||||
|
||||
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,14 +1,11 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
stdsync "sync"
|
||||
"time"
|
||||
|
||||
qfassets "git.mchus.pro/mchus/quoteforge"
|
||||
@@ -20,19 +17,14 @@ import (
|
||||
|
||||
// SyncHandler handles sync API endpoints
|
||||
type SyncHandler struct {
|
||||
localDB *localdb.LocalDB
|
||||
syncService *sync.Service
|
||||
connMgr *db.ConnectionManager
|
||||
autoSyncInterval time.Duration
|
||||
onlineGraceFactor float64
|
||||
tmpl *template.Template
|
||||
readinessMu stdsync.Mutex
|
||||
readinessCached *sync.SyncReadiness
|
||||
readinessCachedAt time.Time
|
||||
localDB *localdb.LocalDB
|
||||
syncService *sync.Service
|
||||
connMgr *db.ConnectionManager
|
||||
tmpl *template.Template
|
||||
}
|
||||
|
||||
// NewSyncHandler creates a new sync handler
|
||||
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, templatesPath 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
|
||||
partialPath := filepath.Join(templatesPath, "partials", "sync_status.html")
|
||||
var tmpl *template.Template
|
||||
@@ -47,35 +39,23 @@ func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr
|
||||
}
|
||||
|
||||
return &SyncHandler{
|
||||
localDB: localDB,
|
||||
syncService: syncService,
|
||||
connMgr: connMgr,
|
||||
autoSyncInterval: autoSyncInterval,
|
||||
onlineGraceFactor: 1.10,
|
||||
tmpl: tmpl,
|
||||
localDB: localDB,
|
||||
syncService: syncService,
|
||||
connMgr: connMgr,
|
||||
tmpl: tmpl,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SyncStatusResponse represents the sync status
|
||||
type SyncStatusResponse struct {
|
||||
LastComponentSync *time.Time `json:"last_component_sync"`
|
||||
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
|
||||
IsOnline bool `json:"is_online"`
|
||||
ComponentsCount int64 `json:"components_count"`
|
||||
PricelistsCount int64 `json:"pricelists_count"`
|
||||
ServerPricelists int `json:"server_pricelists"`
|
||||
NeedComponentSync bool `json:"need_component_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"`
|
||||
LastComponentSync *time.Time `json:"last_component_sync"`
|
||||
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
|
||||
IsOnline bool `json:"is_online"`
|
||||
ComponentsCount int64 `json:"components_count"`
|
||||
PricelistsCount int64 `json:"pricelists_count"`
|
||||
ServerPricelists int `json:"server_pricelists"`
|
||||
NeedComponentSync bool `json:"need_component_sync"`
|
||||
NeedPricelistSync bool `json:"need_pricelist_sync"`
|
||||
}
|
||||
|
||||
// GetStatus returns current sync status
|
||||
@@ -105,7 +85,6 @@ func (h *SyncHandler) GetStatus(c *gin.Context) {
|
||||
|
||||
// Check if component sync is needed (older than 24 hours)
|
||||
needComponentSync := h.localDB.NeedComponentSync(24)
|
||||
readiness := h.getReadinessCached(10 * time.Second)
|
||||
|
||||
c.JSON(http.StatusOK, SyncStatusResponse{
|
||||
LastComponentSync: lastComponentSync,
|
||||
@@ -116,63 +95,9 @@ func (h *SyncHandler) GetStatus(c *gin.Context) {
|
||||
ServerPricelists: serverPricelists,
|
||||
NeedComponentSync: needComponentSync,
|
||||
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
|
||||
type SyncResultResponse struct {
|
||||
Success bool `json:"success"`
|
||||
@@ -184,7 +109,11 @@ type SyncResultResponse struct {
|
||||
// SyncComponents syncs components from MariaDB to local SQLite
|
||||
// POST /api/sync/components
|
||||
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
|
||||
}
|
||||
|
||||
@@ -219,7 +148,11 @@ func (h *SyncHandler) SyncComponents(c *gin.Context) {
|
||||
// SyncPricelists syncs pricelists from MariaDB to local SQLite
|
||||
// POST /api/sync/pricelists
|
||||
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
|
||||
}
|
||||
|
||||
@@ -240,47 +173,30 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) {
|
||||
Synced: synced,
|
||||
Duration: time.Since(startTime).String(),
|
||||
})
|
||||
h.syncService.RecordSyncHeartbeat()
|
||||
}
|
||||
|
||||
// SyncAllResponse represents result of full sync
|
||||
type SyncAllResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
PendingPushed int `json:"pending_pushed"`
|
||||
ComponentsSynced int `json:"components_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"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
ComponentsSynced int `json:"components_synced"`
|
||||
PricelistsSynced int `json:"pricelists_synced"`
|
||||
Duration string `json:"duration"`
|
||||
}
|
||||
|
||||
// SyncAll performs full bidirectional sync:
|
||||
// - push pending local changes (projects/configurations) to server
|
||||
// - pull components, pricelists, projects, and configurations from server
|
||||
// SyncAll syncs both components and pricelists
|
||||
// POST /api/sync/all
|
||||
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
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
var pendingPushed, componentsSynced, pricelistsSynced int
|
||||
|
||||
// Push local pending changes first (projects/configurations)
|
||||
pendingPushed, err := h.syncService.PushPendingChanges()
|
||||
if err != nil {
|
||||
slog.Error("pending push failed during full sync", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": "Pending changes push failed: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
var componentsSynced, pricelistsSynced int
|
||||
|
||||
// Sync components
|
||||
mariaDB, err := h.connMgr.GetDB()
|
||||
@@ -310,56 +226,18 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": "Pricelist sync failed: " + err.Error(),
|
||||
"pending_pushed": pendingPushed,
|
||||
"components_synced": componentsSynced,
|
||||
})
|
||||
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{
|
||||
Success: true,
|
||||
Message: "Full sync completed successfully",
|
||||
PendingPushed: pendingPushed,
|
||||
ComponentsSynced: componentsSynced,
|
||||
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(),
|
||||
Success: true,
|
||||
Message: "Full sync completed successfully",
|
||||
ComponentsSynced: componentsSynced,
|
||||
PricelistsSynced: pricelistsSynced,
|
||||
Duration: time.Since(startTime).String(),
|
||||
})
|
||||
h.syncService.RecordSyncHeartbeat()
|
||||
}
|
||||
|
||||
// checkOnline checks if MariaDB is accessible
|
||||
@@ -370,7 +248,11 @@ func (h *SyncHandler) checkOnline() bool {
|
||||
// PushPendingChanges pushes all pending changes to the server
|
||||
// POST /api/sync/push
|
||||
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
|
||||
}
|
||||
|
||||
@@ -391,7 +273,6 @@ func (h *SyncHandler) PushPendingChanges(c *gin.Context) {
|
||||
Synced: pushed,
|
||||
Duration: time.Since(startTime).String(),
|
||||
})
|
||||
h.syncService.RecordSyncHeartbeat()
|
||||
}
|
||||
|
||||
// GetPendingCount returns the number of pending changes
|
||||
@@ -419,40 +300,12 @@ func (h *SyncHandler) GetPendingChanges(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// SyncInfoResponse represents sync information for the modal
|
||||
// SyncInfoResponse represents sync information
|
||||
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"`
|
||||
|
||||
// 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
|
||||
LastSyncAt *time.Time `json:"last_sync_at"`
|
||||
IsOnline bool `json:"is_online"`
|
||||
ErrorCount int `json:"error_count"`
|
||||
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
|
||||
@@ -467,44 +320,31 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
|
||||
// Check online status by pinging MariaDB
|
||||
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
|
||||
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 != "")
|
||||
errorCount := int(h.localDB.CountErroredChanges())
|
||||
|
||||
// Get pending changes
|
||||
// Get recent errors (last 10)
|
||||
changes, err := h.localDB.GetPendingChanges()
|
||||
if err != nil {
|
||||
slog.Error("failed to get pending changes for sync info", "error", err)
|
||||
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 {
|
||||
// Check if there's a last error and it's not empty
|
||||
if change.LastError != "" {
|
||||
syncErrors = append(syncErrors, SyncError{
|
||||
errors = append(errors, SyncError{
|
||||
Timestamp: change.CreatedAt,
|
||||
Message: change.LastError,
|
||||
})
|
||||
@@ -512,63 +352,15 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Limit to last 10 errors
|
||||
if len(syncErrors) > 10 {
|
||||
syncErrors = syncErrors[:10]
|
||||
if len(errors) > 10 {
|
||||
errors = errors[:10]
|
||||
}
|
||||
|
||||
readiness := h.getReadinessCached(10 * time.Second)
|
||||
|
||||
c.JSON(http.StatusOK, SyncInfoResponse{
|
||||
DBHost: dbHost,
|
||||
DBUser: dbUser,
|
||||
DBName: dbName,
|
||||
IsOnline: isOnline,
|
||||
LastSyncAt: lastPricelistSync,
|
||||
LotCount: lotCount,
|
||||
LotLogCount: lotLogCount,
|
||||
ConfigCount: configCount,
|
||||
ProjectCount: projectCount,
|
||||
PendingChanges: changes,
|
||||
ErrorCount: errorCount,
|
||||
Errors: syncErrors,
|
||||
Readiness: readiness,
|
||||
})
|
||||
}
|
||||
|
||||
// GetUsersStatus returns last sync timestamps for users with sync heartbeats.
|
||||
// GET /api/sync/users-status
|
||||
func (h *SyncHandler) GetUsersStatus(c *gin.Context) {
|
||||
threshold := time.Duration(float64(h.autoSyncInterval) * h.onlineGraceFactor)
|
||||
isOnline := h.checkOnline()
|
||||
|
||||
if !isOnline {
|
||||
c.JSON(http.StatusOK, SyncUsersStatusResponse{
|
||||
IsOnline: false,
|
||||
AutoSyncIntervalSeconds: int64(h.autoSyncInterval.Seconds()),
|
||||
OnlineThresholdSeconds: int64(threshold.Seconds()),
|
||||
GeneratedAt: time.Now().UTC(),
|
||||
Users: []sync.UserSyncStatus{},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Keep current client heartbeat fresh so app version is available in the table.
|
||||
h.syncService.RecordSyncHeartbeat()
|
||||
|
||||
users, err := h.syncService.ListUserSyncStatuses(threshold)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SyncUsersStatusResponse{
|
||||
IsOnline: true,
|
||||
AutoSyncIntervalSeconds: int64(h.autoSyncInterval.Seconds()),
|
||||
OnlineThresholdSeconds: int64(threshold.Seconds()),
|
||||
GeneratedAt: time.Now().UTC(),
|
||||
Users: users,
|
||||
LastSyncAt: lastPricelistSync,
|
||||
IsOnline: isOnline,
|
||||
ErrorCount: errorCount,
|
||||
Errors: errors,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -588,21 +380,12 @@ func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
|
||||
|
||||
// Get pending count
|
||||
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{
|
||||
"IsOffline": isOffline,
|
||||
"PendingCount": pendingCount,
|
||||
"IsBlocked": isBlocked,
|
||||
"BlockedReason": func() string {
|
||||
if readiness == nil {
|
||||
return ""
|
||||
}
|
||||
return readiness.ReasonText
|
||||
}(),
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
@@ -611,24 +394,3 @@ func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,7 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
|
||||
}
|
||||
|
||||
// Load each page template with base
|
||||
simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "pricelists.html", "pricelist_detail.html"}
|
||||
simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "admin_pricing.html", "pricelists.html", "pricelist_detail.html"}
|
||||
for _, page := range simplePages {
|
||||
pagePath := filepath.Join(templatesPath, page)
|
||||
var tmpl *template.Template
|
||||
@@ -197,6 +197,10 @@ func (h *WebHandler) ProjectDetail(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *WebHandler) AdminPricing(c *gin.Context) {
|
||||
h.render(c, "admin_pricing.html", gin.H{"ActivePage": "admin"})
|
||||
}
|
||||
|
||||
func (h *WebHandler) Pricelists(c *gin.Context) {
|
||||
h.render(c, "pricelists.html", gin.H{"ActivePage": "pricelists"})
|
||||
}
|
||||
|
||||
@@ -28,13 +28,14 @@ type ComponentSyncResult struct {
|
||||
func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error) {
|
||||
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
|
||||
type componentRow struct {
|
||||
LotName string
|
||||
LotDescription string
|
||||
Category *string
|
||||
Model *string
|
||||
CurrentPrice *float64
|
||||
}
|
||||
|
||||
var rows []componentRow
|
||||
@@ -43,7 +44,8 @@ func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error)
|
||||
l.lot_name,
|
||||
l.lot_description,
|
||||
COALESCE(c.code, SUBSTRING_INDEX(l.lot_name, '_', 1)) as category,
|
||||
m.model
|
||||
m.model,
|
||||
m.current_price
|
||||
FROM lot l
|
||||
LEFT JOIN qt_lot_metadata m ON l.lot_name = m.lot_name
|
||||
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,
|
||||
Category: category,
|
||||
Model: model,
|
||||
CurrentPrice: row.CurrentPrice,
|
||||
SyncedAt: syncTime,
|
||||
}
|
||||
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
|
||||
var total int64
|
||||
if err := db.Model(&LocalComponent{}).Count(&total).Error; err != nil {
|
||||
@@ -303,3 +312,99 @@ func (l *LocalDB) NeedComponentSync(maxAgeHours int) bool {
|
||||
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,
|
||||
IsTemplate: cfg.IsTemplate,
|
||||
ServerCount: cfg.ServerCount,
|
||||
ServerModel: cfg.ServerModel,
|
||||
SupportCode: cfg.SupportCode,
|
||||
Article: cfg.Article,
|
||||
PricelistID: cfg.PricelistID,
|
||||
OnlyInStock: cfg.OnlyInStock,
|
||||
PriceUpdatedAt: cfg.PriceUpdatedAt,
|
||||
CreatedAt: cfg.CreatedAt,
|
||||
UpdatedAt: time.Now(),
|
||||
@@ -75,11 +70,6 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
|
||||
Notes: local.Notes,
|
||||
IsTemplate: local.IsTemplate,
|
||||
ServerCount: local.ServerCount,
|
||||
ServerModel: local.ServerModel,
|
||||
SupportCode: local.SupportCode,
|
||||
Article: local.Article,
|
||||
PricelistID: local.PricelistID,
|
||||
OnlyInStock: local.OnlyInStock,
|
||||
PriceUpdatedAt: local.PriceUpdatedAt,
|
||||
CreatedAt: local.CreatedAt,
|
||||
}
|
||||
@@ -107,7 +97,6 @@ func ProjectToLocal(project *models.Project) *LocalProject {
|
||||
UUID: project.UUID,
|
||||
OwnerUsername: project.OwnerUsername,
|
||||
Name: project.Name,
|
||||
TrackerURL: project.TrackerURL,
|
||||
IsActive: project.IsActive,
|
||||
IsSystem: project.IsSystem,
|
||||
CreatedAt: project.CreatedAt,
|
||||
@@ -126,7 +115,6 @@ func LocalToProject(local *LocalProject) *models.Project {
|
||||
UUID: local.UUID,
|
||||
OwnerUsername: local.OwnerUsername,
|
||||
Name: local.Name,
|
||||
TrackerURL: local.TrackerURL,
|
||||
IsActive: local.IsActive,
|
||||
IsSystem: local.IsSystem,
|
||||
CreatedAt: local.CreatedAt,
|
||||
@@ -147,7 +135,6 @@ func PricelistToLocal(pl *models.Pricelist) *LocalPricelist {
|
||||
|
||||
return &LocalPricelist{
|
||||
ServerID: pl.ID,
|
||||
Source: pl.Source,
|
||||
Version: pl.Version,
|
||||
Name: name,
|
||||
CreatedAt: pl.CreatedAt,
|
||||
@@ -160,7 +147,6 @@ func PricelistToLocal(pl *models.Pricelist) *LocalPricelist {
|
||||
func LocalToPricelist(local *LocalPricelist) *models.Pricelist {
|
||||
return &models.Pricelist{
|
||||
ID: local.ServerID,
|
||||
Source: local.Source,
|
||||
Version: local.Version,
|
||||
Notification: local.Name,
|
||||
CreatedAt: local.CreatedAt,
|
||||
@@ -170,30 +156,20 @@ func LocalToPricelist(local *LocalPricelist) *models.Pricelist {
|
||||
|
||||
// PricelistItemToLocal converts models.PricelistItem to LocalPricelistItem
|
||||
func PricelistItemToLocal(item *models.PricelistItem, localPricelistID uint) *LocalPricelistItem {
|
||||
partnumbers := make(LocalStringList, 0, len(item.Partnumbers))
|
||||
partnumbers = append(partnumbers, item.Partnumbers...)
|
||||
return &LocalPricelistItem{
|
||||
PricelistID: localPricelistID,
|
||||
LotName: item.LotName,
|
||||
LotCategory: item.LotCategory,
|
||||
Price: item.Price,
|
||||
AvailableQty: item.AvailableQty,
|
||||
Partnumbers: partnumbers,
|
||||
PricelistID: localPricelistID,
|
||||
LotName: item.LotName,
|
||||
Price: item.Price,
|
||||
}
|
||||
}
|
||||
|
||||
// LocalToPricelistItem converts LocalPricelistItem to 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{
|
||||
ID: local.ID,
|
||||
PricelistID: serverPricelistID,
|
||||
LotName: local.LotName,
|
||||
LotCategory: local.LotCategory,
|
||||
Price: local.Price,
|
||||
AvailableQty: local.AvailableQty,
|
||||
Partnumbers: partnumbers,
|
||||
ID: local.ID,
|
||||
PricelistID: serverPricelistID,
|
||||
LotName: local.LotName,
|
||||
Price: local.Price,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,14 +197,17 @@ func ComponentToLocal(meta *models.LotMetadata) *LocalComponent {
|
||||
LotDescription: lotDesc,
|
||||
Category: category,
|
||||
Model: meta.Model,
|
||||
CurrentPrice: meta.CurrentPrice,
|
||||
SyncedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// LocalToComponent converts LocalComponent to models.LotMetadata
|
||||
func LocalToComponent(local *LocalComponent) *models.LotMetadata {
|
||||
return &models.LotMetadata{
|
||||
LotName: local.LotName,
|
||||
Model: local.Model,
|
||||
LotName: local.LotName,
|
||||
Model: local.Model,
|
||||
CurrentPrice: local.CurrentPrice,
|
||||
Lot: &models.Lot{
|
||||
LotName: local.LotName,
|
||||
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,7 +3,6 @@ package localdb
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRunLocalMigrationsBackfillsExistingConfigurations(t *testing.T) {
|
||||
@@ -71,57 +70,3 @@ func TestRunLocalMigrationsBackfillsExistingConfigurations(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,19 +4,15 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||
"github.com/glebarez/sqlite"
|
||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||
uuidpkg "github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
@@ -66,8 +62,6 @@ func New(dbPath string) (*LocalDB, error) {
|
||||
&LocalPricelistItem{},
|
||||
&LocalComponent{},
|
||||
&AppSetting{},
|
||||
&LocalRemoteMigrationApplied{},
|
||||
&LocalSyncGuardState{},
|
||||
&PendingChange{},
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("migrating sqlite database: %w", err)
|
||||
@@ -147,23 +141,19 @@ func (l *LocalDB) GetDSN() (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
cfg := mysqlDriver.NewConfig()
|
||||
cfg.User = settings.User
|
||||
cfg.Passwd = settings.PasswordEncrypted // Contains decrypted password after GetSettings
|
||||
cfg.Net = "tcp"
|
||||
cfg.Addr = net.JoinHostPort(settings.Host, strconv.Itoa(settings.Port))
|
||||
cfg.DBName = settings.Database
|
||||
cfg.ParseTime = true
|
||||
cfg.Loc = time.Local
|
||||
// Add aggressive timeouts for offline-first architecture.
|
||||
cfg.Timeout = 3 * time.Second
|
||||
cfg.ReadTimeout = 3 * time.Second
|
||||
cfg.WriteTimeout = 3 * time.Second
|
||||
cfg.Params = map[string]string{
|
||||
"charset": "utf8mb4",
|
||||
}
|
||||
// Add aggressive timeouts for offline-first architecture
|
||||
// timeout: connection establishment timeout (3s)
|
||||
// readTimeout: I/O read timeout (3s)
|
||||
// writeTimeout: I/O write timeout (3s)
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=3s&readTimeout=3s&writeTimeout=3s",
|
||||
settings.User,
|
||||
settings.PasswordEncrypted, // Contains decrypted password after GetSettings
|
||||
settings.Host,
|
||||
settings.Port,
|
||||
settings.Database,
|
||||
)
|
||||
|
||||
return cfg.FormatDSN(), nil
|
||||
return dsn, nil
|
||||
}
|
||||
|
||||
// DB returns the underlying gorm.DB for advanced operations
|
||||
@@ -420,37 +410,6 @@ func (l *LocalDB) GetConfigurationByUUID(uuid string) (*LocalConfiguration, erro
|
||||
return &config, err
|
||||
}
|
||||
|
||||
// ListConfigurationsWithFilters returns configurations with DB-level filtering and pagination.
|
||||
func (l *LocalDB) ListConfigurationsWithFilters(status string, search string, offset, limit int) ([]LocalConfiguration, int64, error) {
|
||||
query := l.db.Model(&LocalConfiguration{})
|
||||
switch status {
|
||||
case "active":
|
||||
query = query.Where("is_active = ?", true)
|
||||
case "archived":
|
||||
query = query.Where("is_active = ?", false)
|
||||
case "all", "":
|
||||
// no-op
|
||||
default:
|
||||
query = query.Where("is_active = ?", true)
|
||||
}
|
||||
|
||||
search = strings.TrimSpace(search)
|
||||
if search != "" {
|
||||
query = query.Where("LOWER(name) LIKE ?", "%"+strings.ToLower(search)+"%")
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var configs []LocalConfiguration
|
||||
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&configs).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return configs, total, nil
|
||||
}
|
||||
|
||||
// DeleteConfiguration deletes a configuration by UUID
|
||||
func (l *LocalDB) DeleteConfiguration(uuid string) error {
|
||||
return l.DeactivateConfiguration(uuid)
|
||||
@@ -516,13 +475,6 @@ func (l *LocalDB) CountConfigurations() int64 {
|
||||
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
|
||||
|
||||
// GetLastSyncTime returns the last sync timestamp
|
||||
@@ -563,16 +515,7 @@ func (l *LocalDB) CountLocalPricelists() int64 {
|
||||
// GetLatestLocalPricelist returns the most recently synced pricelist
|
||||
func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
|
||||
var pricelist LocalPricelist
|
||||
if err := l.db.Where("source = ?", "estimate").Order("created_at DESC").First(&pricelist).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pricelist, nil
|
||||
}
|
||||
|
||||
// GetLatestLocalPricelistBySource returns the most recently synced pricelist for a source.
|
||||
func (l *LocalDB) GetLatestLocalPricelistBySource(source string) (*LocalPricelist, error) {
|
||||
var pricelist LocalPricelist
|
||||
if err := l.db.Where("source = ?", source).Order("created_at DESC").First(&pricelist).Error; err != nil {
|
||||
if err := l.db.Order("created_at DESC").First(&pricelist).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pricelist, nil
|
||||
@@ -587,24 +530,6 @@ func (l *LocalDB) GetLocalPricelistByServerID(serverID uint) (*LocalPricelist, e
|
||||
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
|
||||
func (l *LocalDB) GetLocalPricelistByID(id uint) (*LocalPricelist, error) {
|
||||
var pricelist LocalPricelist
|
||||
@@ -616,17 +541,7 @@ func (l *LocalDB) GetLocalPricelistByID(id uint) (*LocalPricelist, error) {
|
||||
|
||||
// SaveLocalPricelist saves a pricelist to local SQLite
|
||||
func (l *LocalDB) SaveLocalPricelist(pricelist *LocalPricelist) error {
|
||||
return l.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "server_id"}},
|
||||
DoUpdates: clause.Assignments(map[string]interface{}{
|
||||
"source": pricelist.Source,
|
||||
"version": pricelist.Version,
|
||||
"name": pricelist.Name,
|
||||
"created_at": pricelist.CreatedAt,
|
||||
"synced_at": pricelist.SyncedAt,
|
||||
"is_used": pricelist.IsUsed,
|
||||
}),
|
||||
}).Create(pricelist).Error
|
||||
return l.db.Save(pricelist).Error
|
||||
}
|
||||
|
||||
// GetLocalPricelists returns all local pricelists
|
||||
@@ -645,17 +560,6 @@ func (l *LocalDB) CountLocalPricelistItems(pricelistID uint) int64 {
|
||||
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
|
||||
func (l *LocalDB) SaveLocalPricelistItems(items []LocalPricelistItem) error {
|
||||
if len(items) == 0 {
|
||||
@@ -676,30 +580,6 @@ func (l *LocalDB) SaveLocalPricelistItems(items []LocalPricelistItem) error {
|
||||
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
|
||||
func (l *LocalDB) GetLocalPricelistItems(pricelistID uint) ([]LocalPricelistItem, error) {
|
||||
var items []LocalPricelistItem
|
||||
@@ -719,61 +599,12 @@ func (l *LocalDB) GetLocalPriceForLot(pricelistID uint, lotName string) (float64
|
||||
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
|
||||
func (l *LocalDB) MarkPricelistAsUsed(pricelistID uint, isUsed bool) error {
|
||||
return l.db.Model(&LocalPricelist{}).Where("id = ?", pricelistID).
|
||||
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
|
||||
func (l *LocalDB) DeleteLocalPricelist(id uint) error {
|
||||
// Delete items first
|
||||
@@ -784,47 +615,6 @@ func (l *LocalDB) DeleteLocalPricelist(id uint) 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
|
||||
|
||||
// AddPendingChange adds a change to the sync queue
|
||||
@@ -911,71 +701,3 @@ func (l *LocalDB) PurgeOrphanConfigurationPendingChanges() (int64, error) {
|
||||
func (l *LocalDB) GetPendingCount() int64 {
|
||||
return l.CountPendingChanges()
|
||||
}
|
||||
|
||||
// GetRemoteMigrationApplied returns a locally applied remote migration by ID.
|
||||
func (l *LocalDB) GetRemoteMigrationApplied(id string) (*LocalRemoteMigrationApplied, error) {
|
||||
var migration LocalRemoteMigrationApplied
|
||||
if err := l.db.Where("id = ?", id).First(&migration).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &migration, nil
|
||||
}
|
||||
|
||||
// UpsertRemoteMigrationApplied writes applied migration metadata.
|
||||
func (l *LocalDB) UpsertRemoteMigrationApplied(id, checksum, appVersion string, appliedAt time.Time) error {
|
||||
record := &LocalRemoteMigrationApplied{
|
||||
ID: id,
|
||||
Checksum: checksum,
|
||||
AppVersion: appVersion,
|
||||
AppliedAt: appliedAt,
|
||||
}
|
||||
return l.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
DoUpdates: clause.Assignments(map[string]interface{}{
|
||||
"checksum": checksum,
|
||||
"app_version": appVersion,
|
||||
"applied_at": appliedAt,
|
||||
}),
|
||||
}).Create(record).Error
|
||||
}
|
||||
|
||||
// GetLatestAppliedRemoteMigrationID returns last applied remote migration id.
|
||||
func (l *LocalDB) GetLatestAppliedRemoteMigrationID() (string, error) {
|
||||
var record LocalRemoteMigrationApplied
|
||||
if err := l.db.Order("applied_at DESC").First(&record).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return record.ID, nil
|
||||
}
|
||||
|
||||
// GetSyncGuardState returns the latest readiness guard state.
|
||||
func (l *LocalDB) GetSyncGuardState() (*LocalSyncGuardState, error) {
|
||||
var state LocalSyncGuardState
|
||||
if err := l.db.Order("id DESC").First(&state).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
// SetSyncGuardState upserts readiness guard state (single-row logical table).
|
||||
func (l *LocalDB) SetSyncGuardState(status, reasonCode, reasonText string, requiredMinAppVersion *string, checkedAt *time.Time) error {
|
||||
state := &LocalSyncGuardState{
|
||||
ID: 1,
|
||||
Status: status,
|
||||
ReasonCode: reasonCode,
|
||||
ReasonText: reasonText,
|
||||
RequiredMinAppVersion: requiredMinAppVersion,
|
||||
LastCheckedAt: checkedAt,
|
||||
}
|
||||
return l.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
DoUpdates: clause.Assignments(map[string]interface{}{
|
||||
"status": status,
|
||||
"reason_code": reasonCode,
|
||||
"reason_text": reasonText,
|
||||
"required_min_app_version": requiredMinAppVersion,
|
||||
"last_checked_at": checkedAt,
|
||||
"updated_at": time.Now(),
|
||||
}),
|
||||
}).Create(state).Error
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -43,51 +42,6 @@ var localMigrations = []localMigration{
|
||||
name: "Create default projects and attach existing configurations",
|
||||
run: backfillProjectsForConfigurations,
|
||||
},
|
||||
{
|
||||
id: "2026_02_06_pricelist_backfill",
|
||||
name: "Attach existing configurations to latest local pricelist and recalc usage",
|
||||
run: backfillConfigurationPricelists,
|
||||
},
|
||||
{
|
||||
id: "2026_02_06_pricelist_index_fix",
|
||||
name: "Use unique server_id for local pricelists and allow duplicate versions",
|
||||
run: fixLocalPricelistIndexes,
|
||||
},
|
||||
{
|
||||
id: "2026_02_06_pricelist_source",
|
||||
name: "Backfill source for local pricelists and create source indexes",
|
||||
run: backfillLocalPricelistSource,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
func runLocalMigrations(db *gorm.DB) error {
|
||||
@@ -238,330 +192,9 @@ func ensureDefaultProjectTx(tx *gorm.DB, ownerUsername string) (*LocalProject, e
|
||||
return &project, nil
|
||||
}
|
||||
|
||||
func backfillConfigurationPricelists(tx *gorm.DB) error {
|
||||
var latest LocalPricelist
|
||||
if err := tx.Where("source = ?", "estimate").Order("created_at DESC").First(&latest).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("load latest local pricelist: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Model(&LocalConfiguration{}).
|
||||
Where("pricelist_id IS NULL").
|
||||
Update("pricelist_id", latest.ServerID).Error; err != nil {
|
||||
return fmt.Errorf("backfill configuration pricelist_id: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Model(&LocalPricelist{}).Where("1 = 1").Update("is_used", false).Error; err != nil {
|
||||
return fmt.Errorf("reset local pricelist usage flags: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
UPDATE local_pricelists
|
||||
SET is_used = 1
|
||||
WHERE server_id IN (
|
||||
SELECT DISTINCT pricelist_id
|
||||
FROM local_configurations
|
||||
WHERE pricelist_id IS NOT NULL AND is_active = 1
|
||||
)
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("recalculate local pricelist usage flags: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func chooseNonZeroTime(candidate time.Time, fallback time.Time) time.Time {
|
||||
if candidate.IsZero() {
|
||||
return fallback
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
func fixLocalPricelistIndexes(tx *gorm.DB) error {
|
||||
type indexRow struct {
|
||||
Name string `gorm:"column:name"`
|
||||
Unique int `gorm:"column:unique"`
|
||||
}
|
||||
var indexes []indexRow
|
||||
if err := tx.Raw("PRAGMA index_list('local_pricelists')").Scan(&indexes).Error; err != nil {
|
||||
return fmt.Errorf("list local_pricelists indexes: %w", err)
|
||||
}
|
||||
|
||||
for _, idx := range indexes {
|
||||
if idx.Unique == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
type indexInfoRow struct {
|
||||
Name string `gorm:"column:name"`
|
||||
}
|
||||
var info []indexInfoRow
|
||||
if err := tx.Raw(fmt.Sprintf("PRAGMA index_info('%s')", strings.ReplaceAll(idx.Name, "'", "''"))).Scan(&info).Error; err != nil {
|
||||
return fmt.Errorf("load index info for %s: %w", idx.Name, err)
|
||||
}
|
||||
if len(info) != 1 || info[0].Name != "version" {
|
||||
continue
|
||||
}
|
||||
|
||||
quoted := strings.ReplaceAll(idx.Name, `"`, `""`)
|
||||
if err := tx.Exec(fmt.Sprintf(`DROP INDEX IF EXISTS "%s"`, quoted)).Error; err != nil {
|
||||
return fmt.Errorf("drop unique version index %s: %w", idx.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_local_pricelists_server_id
|
||||
ON local_pricelists(server_id)
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("ensure unique index local_pricelists(server_id): %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_local_pricelists_version
|
||||
ON local_pricelists(version)
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("ensure index local_pricelists(version): %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func backfillLocalPricelistSource(tx *gorm.DB) error {
|
||||
if err := tx.Exec(`
|
||||
UPDATE local_pricelists
|
||||
SET source = 'estimate'
|
||||
WHERE source IS NULL OR source = ''
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("backfill local_pricelists.source: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_local_pricelists_source_created_at
|
||||
ON local_pricelists(source, created_at DESC)
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("ensure idx_local_pricelists_source_created_at: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
type LocalConfiguration struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
@@ -96,13 +72,6 @@ type LocalConfiguration struct {
|
||||
Notes string `json:"notes"`
|
||||
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
||||
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"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
@@ -124,7 +93,6 @@ type LocalProject struct {
|
||||
ServerID *uint `json:"server_id,omitempty"`
|
||||
OwnerUsername string `gorm:"not null;index" json:"owner_username"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
TrackerURL string `json:"tracker_url"`
|
||||
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
||||
IsSystem bool `gorm:"default:false;index" json:"is_system"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
@@ -157,11 +125,10 @@ func (LocalConfigurationVersion) TableName() string {
|
||||
// LocalPricelist stores cached pricelists from server
|
||||
type LocalPricelist struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
ServerID uint `gorm:"not null;uniqueIndex" 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:"not null;index" json:"version"`
|
||||
ServerID uint `gorm:"not null" json:"server_id"` // ID on MariaDB server
|
||||
Version string `gorm:"uniqueIndex;not null" json:"version"`
|
||||
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"`
|
||||
IsUsed bool `gorm:"default:false" json:"is_used"` // Used by any local configuration
|
||||
}
|
||||
@@ -172,59 +139,30 @@ func (LocalPricelist) TableName() string {
|
||||
|
||||
// LocalPricelistItem stores pricelist items
|
||||
type LocalPricelistItem struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
PricelistID uint `gorm:"not null;index" json:"pricelist_id"`
|
||||
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"`
|
||||
AvailableQty *float64 `json:"available_qty,omitempty"`
|
||||
Partnumbers LocalStringList `gorm:"type:text" json:"partnumbers,omitempty"`
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
PricelistID uint `gorm:"not null;index" json:"pricelist_id"`
|
||||
LotName string `gorm:"not null" json:"lot_name"`
|
||||
Price float64 `gorm:"not null" json:"price"`
|
||||
}
|
||||
|
||||
func (LocalPricelistItem) TableName() string {
|
||||
return "local_pricelist_items"
|
||||
}
|
||||
|
||||
// LocalComponent stores cached components for offline search (metadata only)
|
||||
// All pricing is now sourced from local_pricelist_items based on configuration pricelist selection
|
||||
// LocalComponent stores cached components for offline search
|
||||
type LocalComponent struct {
|
||||
LotName string `gorm:"primaryKey" json:"lot_name"`
|
||||
LotDescription string `json:"lot_description"`
|
||||
Category string `json:"category"`
|
||||
Model string `json:"model"`
|
||||
LotName string `gorm:"primaryKey" json:"lot_name"`
|
||||
LotDescription string `json:"lot_description"`
|
||||
Category string `json:"category"`
|
||||
Model string `json:"model"`
|
||||
CurrentPrice *float64 `json:"current_price"`
|
||||
SyncedAt time.Time `json:"synced_at"`
|
||||
}
|
||||
|
||||
func (LocalComponent) TableName() string {
|
||||
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
|
||||
type PendingChange struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
|
||||
@@ -22,11 +22,6 @@ func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
|
||||
"notes": localCfg.Notes,
|
||||
"is_template": localCfg.IsTemplate,
|
||||
"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,
|
||||
"created_at": localCfg.CreatedAt,
|
||||
"updated_at": localCfg.UpdatedAt,
|
||||
@@ -55,11 +50,6 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
|
||||
Notes string `json:"notes"`
|
||||
IsTemplate bool `json:"is_template"`
|
||||
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"`
|
||||
OriginalUserID uint `json:"original_user_id"`
|
||||
OriginalUsername string `json:"original_username"`
|
||||
@@ -84,11 +74,6 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
|
||||
Notes: snapshot.Notes,
|
||||
IsTemplate: snapshot.IsTemplate,
|
||||
ServerCount: snapshot.ServerCount,
|
||||
ServerModel: snapshot.ServerModel,
|
||||
SupportCode: snapshot.SupportCode,
|
||||
Article: snapshot.Article,
|
||||
PricelistID: snapshot.PricelistID,
|
||||
OnlyInStock: snapshot.OnlyInStock,
|
||||
PriceUpdatedAt: snapshot.PriceUpdatedAt,
|
||||
OriginalUserID: snapshot.OriginalUserID,
|
||||
OriginalUsername: snapshot.OriginalUsername,
|
||||
|
||||
@@ -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
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func CORS() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
origin := strings.TrimSpace(c.GetHeader("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-Headers", "Origin, Content-Type, Accept, Authorization")
|
||||
c.Header("Access-Control-Expose-Headers", "Content-Length, Content-Disposition")
|
||||
c.Header("Access-Control-Max-Age", "86400")
|
||||
} else if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization")
|
||||
c.Header("Access-Control-Expose-Headers", "Content-Length, Content-Disposition")
|
||||
c.Header("Access-Control-Max-Age", "86400")
|
||||
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func isLoopbackOrigin(origin string) bool {
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return false
|
||||
}
|
||||
host := strings.TrimSpace(u.Hostname())
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
if strings.EqualFold(host, "localhost") {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
@@ -40,29 +40,21 @@ func (c ConfigItems) Total() float64 {
|
||||
}
|
||||
|
||||
type Configuration struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
|
||||
UserID *uint `json:"user_id,omitempty"` // Legacy field, no longer required for ownership
|
||||
OwnerUsername string `gorm:"size:100;not null;default:'';index" json:"owner_username"`
|
||||
ProjectUUID *string `gorm:"size:36;index" json:"project_uuid,omitempty"`
|
||||
AppVersion string `gorm:"size:64" json:"app_version,omitempty"`
|
||||
Name string `gorm:"size:200;not null" json:"name"`
|
||||
Items ConfigItems `gorm:"type:json;not null" json:"items"`
|
||||
TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"`
|
||||
CustomPrice *float64 `gorm:"type:decimal(12,2)" json:"custom_price"`
|
||||
Notes string `gorm:"type:text" json:"notes"`
|
||||
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
||||
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"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
|
||||
UserID *uint `json:"user_id,omitempty"` // Legacy field, no longer required for ownership
|
||||
OwnerUsername string `gorm:"size:100;not null;default:'';index" json:"owner_username"`
|
||||
ProjectUUID *string `gorm:"size:36;index" json:"project_uuid,omitempty"`
|
||||
AppVersion string `gorm:"size:64" json:"app_version,omitempty"`
|
||||
Name string `gorm:"size:200;not null" json:"name"`
|
||||
Items ConfigItems `gorm:"type:json;not null" json:"items"`
|
||||
TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"`
|
||||
CustomPrice *float64 `gorm:"type:decimal(12,2)" json:"custom_price"`
|
||||
Notes string `gorm:"type:text" json:"notes"`
|
||||
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
||||
ServerCount int `gorm:"default:1" json:"server_count"`
|
||||
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
|
||||
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
}
|
||||
|
||||
@@ -37,44 +37,3 @@ type Supplier struct {
|
||||
func (Supplier) TableName() string {
|
||||
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"
|
||||
)
|
||||
|
||||
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
|
||||
type Pricelist struct {
|
||||
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;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
|
||||
CreatedAt time.Time `gorm:"index:idx_qt_pricelists_source_created_at,priority:2,sort:desc" json:"created_at"`
|
||||
Version string `gorm:"size:20;uniqueIndex;not null" json:"version"` // Format: YYYY-MM-DD-NNN
|
||||
Notification string `gorm:"size:500" json:"notification"` // Notification shown in configurator
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CreatedBy string `gorm:"size:100" json:"created_by"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
UsageCount int `gorm:"default:0" json:"usage_count"`
|
||||
@@ -55,7 +26,6 @@ type PricelistItem struct {
|
||||
ID uint `gorm:"primaryKey" json:"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"`
|
||||
LotCategory string `gorm:"column:lot_category;size:50" json:"lot_category,omitempty"`
|
||||
Price float64 `gorm:"type:decimal(12,2);not null" json:"price"`
|
||||
PriceMethod string `gorm:"size:20" json:"price_method"`
|
||||
|
||||
@@ -66,10 +36,8 @@ type PricelistItem struct {
|
||||
MetaPrices string `gorm:"size:1000" json:"meta_prices,omitempty"`
|
||||
|
||||
// Virtual fields for display
|
||||
LotDescription string `gorm:"-" json:"lot_description,omitempty"`
|
||||
Category string `gorm:"-" json:"category,omitempty"`
|
||||
AvailableQty *float64 `gorm:"-" json:"available_qty,omitempty"`
|
||||
Partnumbers []string `gorm:"-" json:"partnumbers,omitempty"`
|
||||
LotDescription string `gorm:"-" json:"lot_description,omitempty"`
|
||||
Category string `gorm:"-" json:"category,omitempty"`
|
||||
}
|
||||
|
||||
func (PricelistItem) TableName() string {
|
||||
@@ -79,7 +47,6 @@ func (PricelistItem) TableName() string {
|
||||
// PricelistSummary is used for list views
|
||||
type PricelistSummary struct {
|
||||
ID uint `json:"id"`
|
||||
Source string `json:"source"`
|
||||
Version string `json:"version"`
|
||||
Notification string `json:"notification"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
@@ -7,7 +7,6 @@ type Project struct {
|
||||
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
|
||||
OwnerUsername string `gorm:"size:100;not null;index" json:"owner_username"`
|
||||
Name string `gorm:"size:200;not null" json:"name"`
|
||||
TrackerURL string `gorm:"size:500" json:"tracker_url"`
|
||||
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
||||
IsSystem bool `gorm:"default:false;index" json:"is_system"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
|
||||
@@ -110,10 +110,6 @@ func (r *ComponentRepository) Update(component *models.LotMetadata) error {
|
||||
return r.db.Save(component).Error
|
||||
}
|
||||
|
||||
func (r *ComponentRepository) DB() *gorm.DB {
|
||||
return r.db
|
||||
}
|
||||
|
||||
func (r *ComponentRepository) Create(component *models.LotMetadata) error {
|
||||
return r.db.Create(component).Error
|
||||
}
|
||||
|
||||
@@ -3,12 +3,10 @@ package repository
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/lotmatch"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -23,24 +21,13 @@ func NewPricelistRepository(db *gorm.DB) *PricelistRepository {
|
||||
|
||||
// List returns pricelists with pagination
|
||||
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
|
||||
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)
|
||||
}
|
||||
|
||||
var pricelists []models.Pricelist
|
||||
if err := query.Order("created_at 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)
|
||||
}
|
||||
|
||||
@@ -49,25 +36,13 @@ func (r *PricelistRepository) ListBySource(source string, offset, limit int) ([]
|
||||
|
||||
// ListActive returns active pricelists with pagination.
|
||||
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
|
||||
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)
|
||||
}
|
||||
|
||||
var pricelists []models.Pricelist
|
||||
if err := query.Order("created_at 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)
|
||||
}
|
||||
|
||||
@@ -89,17 +64,15 @@ func (r *PricelistRepository) toSummaries(pricelists []models.Pricelist) []model
|
||||
for i, pl := range pricelists {
|
||||
var itemCount int64
|
||||
r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", pl.ID).Count(&itemCount)
|
||||
usageCount, _ := r.CountUsage(pl.ID)
|
||||
|
||||
summaries[i] = models.PricelistSummary{
|
||||
ID: pl.ID,
|
||||
Source: pl.Source,
|
||||
Version: pl.Version,
|
||||
Notification: pl.Notification,
|
||||
CreatedAt: pl.CreatedAt,
|
||||
CreatedBy: pl.CreatedBy,
|
||||
IsActive: pl.IsActive,
|
||||
UsageCount: int(usageCount),
|
||||
UsageCount: pl.UsageCount,
|
||||
ExpiresAt: pl.ExpiresAt,
|
||||
ItemCount: itemCount,
|
||||
}
|
||||
@@ -119,22 +92,14 @@ func (r *PricelistRepository) GetByID(id uint) (*models.Pricelist, error) {
|
||||
var itemCount int64
|
||||
r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", id).Count(&itemCount)
|
||||
pricelist.ItemCount = int(itemCount)
|
||||
if usageCount, err := r.CountUsage(id); err == nil {
|
||||
pricelist.UsageCount = int(usageCount)
|
||||
}
|
||||
|
||||
return &pricelist, nil
|
||||
}
|
||||
|
||||
// GetByVersion returns a pricelist by version string
|
||||
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
|
||||
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 &pricelist, nil
|
||||
@@ -142,13 +107,8 @@ func (r *PricelistRepository) GetBySourceAndVersion(source, version string) (*mo
|
||||
|
||||
// GetLatestActive returns the most recent active pricelist
|
||||
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
|
||||
if err := r.db.Where("is_active = ? AND source = ?", true, source).Order("created_at DESC").First(&pricelist).Error; err != nil {
|
||||
if err := r.db.Where("is_active = ?", true).Order("created_at DESC").First(&pricelist).Error; err != nil {
|
||||
return nil, fmt.Errorf("getting latest pricelist: %w", err)
|
||||
}
|
||||
return &pricelist, nil
|
||||
@@ -172,13 +132,13 @@ func (r *PricelistRepository) Update(pricelist *models.Pricelist) error {
|
||||
|
||||
// Delete deletes a pricelist if usage_count is 0
|
||||
func (r *PricelistRepository) Delete(id uint) error {
|
||||
usageCount, err := r.CountUsage(id)
|
||||
pricelist, err := r.GetByID(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if usageCount > 0 {
|
||||
return fmt.Errorf("cannot delete pricelist with usage_count > 0 (current: %d)", usageCount)
|
||||
if pricelist.UsageCount > 0 {
|
||||
return fmt.Errorf("cannot delete pricelist with usage_count > 0 (current: %d)", pricelist.UsageCount)
|
||||
}
|
||||
|
||||
// Delete items first
|
||||
@@ -238,163 +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 {
|
||||
items[i].LotDescription = lot.LotDescription
|
||||
}
|
||||
items[i].Category = strings.TrimSpace(items[i].LotCategory)
|
||||
}
|
||||
|
||||
if err := r.enrichItemsWithStock(items); err != nil {
|
||||
return nil, 0, fmt.Errorf("enriching pricelist items with stock: %w", err)
|
||||
// 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]
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
func (r *PricelistRepository) GenerateVersion() (string, error) {
|
||||
return r.GenerateVersionBySource(string(models.PricelistSourceEstimate))
|
||||
}
|
||||
|
||||
// GenerateVersionBySource generates a new version string in format YYYY-MM-DD-NNN scoped by source.
|
||||
func (r *PricelistRepository) GenerateVersionBySource(source string) (string, error) {
|
||||
today := time.Now().Format("2006-01-02")
|
||||
prefix := versionPrefixBySource(source)
|
||||
|
||||
var last models.Pricelist
|
||||
err := r.db.Model(&models.Pricelist{}).
|
||||
Select("version").
|
||||
Where("source = ? AND version LIKE ?", source, prefix+"-"+today+"-%").
|
||||
Where("version LIKE ?", today+"-%").
|
||||
Order("version DESC").
|
||||
Limit(1).
|
||||
Take(&last).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fmt.Sprintf("%s-%s-001", prefix, today), nil
|
||||
return fmt.Sprintf("%s-001", today), nil
|
||||
}
|
||||
return "", fmt.Errorf("loading latest today's pricelist version: %w", err)
|
||||
}
|
||||
@@ -409,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.Sprintf("%s-%s-%03d", prefix, today, n+1), nil
|
||||
}
|
||||
|
||||
func versionPrefixBySource(source string) string {
|
||||
switch models.NormalizePricelistSource(source) {
|
||||
case models.PricelistSourceWarehouse:
|
||||
return "S"
|
||||
case models.PricelistSourceCompetitor:
|
||||
return "B"
|
||||
default:
|
||||
return "E"
|
||||
}
|
||||
}
|
||||
|
||||
// GetPriceForLotBySource returns item price for a lot from latest active pricelist of source.
|
||||
func (r *PricelistRepository) GetPriceForLotBySource(source, lotName string) (float64, uint, error) {
|
||||
latest, err := r.GetLatestActiveBySource(source)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
price, err := r.GetPriceForLot(latest.ID, lotName)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return price, latest.ID, nil
|
||||
return fmt.Sprintf("%s-%03d", today, n+1), nil
|
||||
}
|
||||
|
||||
// CanWrite checks if the current database user has INSERT permission on qt_pricelists
|
||||
@@ -492,15 +295,6 @@ func (r *PricelistRepository) DecrementUsageCount(id uint) 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
|
||||
func (r *PricelistRepository) GetExpiredUnused() ([]models.Pricelist, error) {
|
||||
var pricelists []models.Pricelist
|
||||
|
||||
@@ -13,13 +13,13 @@ import (
|
||||
func TestGenerateVersion_FirstOfDay(t *testing.T) {
|
||||
repo := newTestPricelistRepository(t)
|
||||
|
||||
version, err := repo.GenerateVersionBySource(string(models.PricelistSourceEstimate))
|
||||
version, err := repo.GenerateVersion()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateVersionBySource returned error: %v", err)
|
||||
t.Fatalf("GenerateVersion returned error: %v", err)
|
||||
}
|
||||
|
||||
today := time.Now().Format("2006-01-02")
|
||||
want := fmt.Sprintf("E-%s-001", today)
|
||||
want := fmt.Sprintf("%s-001", today)
|
||||
if version != want {
|
||||
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")
|
||||
|
||||
seed := []models.Pricelist{
|
||||
{Source: string(models.PricelistSourceEstimate), Version: fmt.Sprintf("E-%s-001", today), CreatedBy: "test", IsActive: true},
|
||||
{Source: string(models.PricelistSourceEstimate), Version: fmt.Sprintf("E-%s-003", today), CreatedBy: "test", IsActive: true},
|
||||
{Version: fmt.Sprintf("%s-001", today), CreatedBy: "test", IsActive: true},
|
||||
{Version: fmt.Sprintf("%s-003", today), CreatedBy: "test", IsActive: true},
|
||||
}
|
||||
for _, pl := range seed {
|
||||
if err := repo.Create(&pl); err != nil {
|
||||
@@ -39,93 +39,17 @@ func TestGenerateVersion_UsesMaxSuffixNotCount(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
version, err := repo.GenerateVersionBySource(string(models.PricelistSourceEstimate))
|
||||
version, err := repo.GenerateVersion()
|
||||
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 {
|
||||
t.Fatalf("expected %s, got %s", want, version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateVersion_IsolatedBySource(t *testing.T) {
|
||||
repo := newTestPricelistRepository(t)
|
||||
today := time.Now().Format("2006-01-02")
|
||||
|
||||
seed := []models.Pricelist{
|
||||
{Source: string(models.PricelistSourceEstimate), Version: fmt.Sprintf("E-%s-009", today), CreatedBy: "test", IsActive: true},
|
||||
{Source: string(models.PricelistSourceWarehouse), Version: fmt.Sprintf("S-%s-002", today), CreatedBy: "test", IsActive: true},
|
||||
}
|
||||
for _, pl := range seed {
|
||||
if err := repo.Create(&pl); err != nil {
|
||||
t.Fatalf("seed insert failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
version, err := repo.GenerateVersionBySource(string(models.PricelistSourceWarehouse))
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateVersionBySource returned error: %v", err)
|
||||
}
|
||||
|
||||
want := fmt.Sprintf("S-%s-003", today)
|
||||
if version != want {
|
||||
t.Fatalf("expected %s, got %s", want, version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetItems_WarehouseAvailableQtyUsesPrefixResolver(t *testing.T) {
|
||||
repo := newTestPricelistRepository(t)
|
||||
db := repo.db
|
||||
|
||||
warehouse := models.Pricelist{
|
||||
Source: string(models.PricelistSourceWarehouse),
|
||||
Version: "S-2026-02-07-001",
|
||||
CreatedBy: "test",
|
||||
IsActive: true,
|
||||
}
|
||||
if err := db.Create(&warehouse).Error; err != nil {
|
||||
t.Fatalf("create pricelist: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.PricelistItem{
|
||||
PricelistID: warehouse.ID,
|
||||
LotName: "SSD_NVME_03.2T",
|
||||
Price: 100,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create pricelist item: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.Lot{LotName: "SSD_NVME_03.2T"}).Error; err != nil {
|
||||
t.Fatalf("create lot: %v", err)
|
||||
}
|
||||
qty := 5.0
|
||||
if err := db.Create(&models.StockLog{
|
||||
Partnumber: "SSD_NVME_03.2T_GEN3_P4610",
|
||||
Date: time.Now(),
|
||||
Price: 200,
|
||||
Qty: &qty,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create stock log: %v", err)
|
||||
}
|
||||
|
||||
items, total, err := repo.GetItems(warehouse.ID, 0, 20, "")
|
||||
if err != nil {
|
||||
t.Fatalf("GetItems: %v", err)
|
||||
}
|
||||
if total != 1 {
|
||||
t.Fatalf("expected total=1, got %d", total)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected 1 item, got %d", len(items))
|
||||
}
|
||||
if items[0].AvailableQty == nil {
|
||||
t.Fatalf("expected available qty to be set")
|
||||
}
|
||||
if *items[0].AvailableQty != 5 {
|
||||
t.Fatalf("expected available qty=5, got %v", *items[0].AvailableQty)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestPricelistRepository(t *testing.T) *PricelistRepository {
|
||||
t.Helper()
|
||||
|
||||
@@ -133,7 +57,7 @@ func newTestPricelistRepository(t *testing.T) *PricelistRepository {
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.Pricelist{}, &models.PricelistItem{}, &models.Lot{}, &models.LotPartnumber{}, &models.StockLog{}); err != nil {
|
||||
if err := db.AutoMigrate(&models.Pricelist{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return NewPricelistRepository(db)
|
||||
|
||||
@@ -3,7 +3,6 @@ package repository
|
||||
import (
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type ProjectRepository struct {
|
||||
@@ -22,30 +21,6 @@ func (r *ProjectRepository) Update(project *models.Project) error {
|
||||
return r.db.Save(project).Error
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) UpsertByUUID(project *models.Project) error {
|
||||
if err := r.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "uuid"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"owner_username",
|
||||
"name",
|
||||
"tracker_url",
|
||||
"is_active",
|
||||
"is_system",
|
||||
"updated_at",
|
||||
}),
|
||||
}).Create(project).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Ensure caller always gets canonical server ID.
|
||||
var persisted models.Project
|
||||
if err := r.db.Where("uuid = ?", project.UUID).First(&persisted).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
project.ID = persisted.ID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) GetByUUID(uuid string) (*models.Project, error) {
|
||||
var project models.Project
|
||||
if err := r.db.Where("uuid = ?", uuid).First(&project).Error; err != nil {
|
||||
|
||||
@@ -83,6 +83,10 @@ func (r *UnifiedRepo) getComponentsOffline(filter ComponentFilter, offset, limit
|
||||
search := "%" + filter.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
|
||||
query.Count(&total)
|
||||
|
||||
@@ -92,6 +96,8 @@ func (r *UnifiedRepo) getComponentsOffline(filter ComponentFilter, offset, limit
|
||||
sortDir = "DESC"
|
||||
}
|
||||
switch filter.SortField {
|
||||
case "current_price":
|
||||
query = query.Order("current_price " + sortDir)
|
||||
case "lot_name":
|
||||
query = query.Order("lot_name " + sortDir)
|
||||
default:
|
||||
@@ -106,8 +112,9 @@ func (r *UnifiedRepo) getComponentsOffline(filter ComponentFilter, offset, limit
|
||||
result := make([]models.LotMetadata, len(components))
|
||||
for i, comp := range components {
|
||||
result[i] = models.LotMetadata{
|
||||
LotName: comp.LotName,
|
||||
Model: comp.Model,
|
||||
LotName: comp.LotName,
|
||||
Model: comp.Model,
|
||||
CurrentPrice: comp.CurrentPrice,
|
||||
Lot: &models.Lot{
|
||||
LotName: comp.LotName,
|
||||
LotDescription: comp.LotDescription,
|
||||
@@ -131,8 +138,9 @@ func (r *UnifiedRepo) GetComponent(lotName string) (*models.LotMetadata, error)
|
||||
}
|
||||
|
||||
return &models.LotMetadata{
|
||||
LotName: comp.LotName,
|
||||
Model: comp.Model,
|
||||
LotName: comp.LotName,
|
||||
Model: comp.Model,
|
||||
CurrentPrice: comp.CurrentPrice,
|
||||
Lot: &models.Lot{
|
||||
LotName: comp.LotName,
|
||||
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"`
|
||||
CategoryName string `json:"category_name"`
|
||||
Model string `json:"model"`
|
||||
CurrentPrice *float64 `json:"current_price"`
|
||||
PriceFreshness models.PriceFreshness `json:"price_freshness"`
|
||||
PopularityScore float64 `json:"popularity_score"`
|
||||
Specs models.Specs `json:"specs,omitempty"`
|
||||
@@ -91,6 +92,7 @@ func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage
|
||||
view := ComponentView{
|
||||
LotName: c.LotName,
|
||||
Model: c.Model,
|
||||
CurrentPrice: c.CurrentPrice,
|
||||
PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3),
|
||||
PopularityScore: c.PopularityScore,
|
||||
Specs: c.Specs,
|
||||
@@ -132,6 +134,7 @@ func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error)
|
||||
view := &ComponentView{
|
||||
LotName: c.LotName,
|
||||
Model: c.Model,
|
||||
CurrentPrice: c.CurrentPrice,
|
||||
PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3),
|
||||
PopularityScore: c.PopularityScore,
|
||||
Specs: c.Specs,
|
||||
|
||||
@@ -24,7 +24,6 @@ type ConfigurationService struct {
|
||||
configRepo *repository.ConfigurationRepository
|
||||
projectRepo *repository.ProjectRepository
|
||||
componentRepo *repository.ComponentRepository
|
||||
pricelistRepo *repository.PricelistRepository
|
||||
quoteService *QuoteService
|
||||
}
|
||||
|
||||
@@ -32,14 +31,12 @@ func NewConfigurationService(
|
||||
configRepo *repository.ConfigurationRepository,
|
||||
projectRepo *repository.ProjectRepository,
|
||||
componentRepo *repository.ComponentRepository,
|
||||
pricelistRepo *repository.PricelistRepository,
|
||||
quoteService *QuoteService,
|
||||
) *ConfigurationService {
|
||||
return &ConfigurationService{
|
||||
configRepo: configRepo,
|
||||
projectRepo: projectRepo,
|
||||
componentRepo: componentRepo,
|
||||
pricelistRepo: pricelistRepo,
|
||||
quoteService: quoteService,
|
||||
}
|
||||
}
|
||||
@@ -52,18 +49,6 @@ type CreateConfigRequest struct {
|
||||
Notes string `json:"notes"`
|
||||
IsTemplate bool `json:"is_template"`
|
||||
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) {
|
||||
@@ -71,10 +56,6 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
total := req.Items.Total()
|
||||
|
||||
@@ -94,11 +75,6 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq
|
||||
Notes: req.Notes,
|
||||
IsTemplate: req.IsTemplate,
|
||||
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 {
|
||||
@@ -139,10 +115,6 @@ func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *Cr
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
total := req.Items.Total()
|
||||
|
||||
@@ -159,11 +131,6 @@ func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *Cr
|
||||
config.Notes = req.Notes
|
||||
config.IsTemplate = req.IsTemplate
|
||||
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 {
|
||||
return nil, err
|
||||
@@ -240,8 +207,6 @@ func (s *ConfigurationService) CloneToProject(configUUID string, ownerUsername s
|
||||
Notes: original.Notes,
|
||||
IsTemplate: false, // Clone is never a template
|
||||
ServerCount: original.ServerCount,
|
||||
PricelistID: original.PricelistID,
|
||||
OnlyInStock: original.OnlyInStock,
|
||||
}
|
||||
|
||||
if err := s.configRepo.Create(clone); err != nil {
|
||||
@@ -296,10 +261,6 @@ func (s *ConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigReques
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
total := req.Items.Total()
|
||||
if req.ServerCount > 1 {
|
||||
@@ -314,8 +275,6 @@ func (s *ConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigReques
|
||||
config.Notes = req.Notes
|
||||
config.IsTemplate = req.IsTemplate
|
||||
config.ServerCount = req.ServerCount
|
||||
config.PricelistID = pricelistID
|
||||
config.OnlyInStock = req.OnlyInStock
|
||||
|
||||
if err := s.configRepo.Update(config); err != nil {
|
||||
return nil, err
|
||||
@@ -382,8 +341,6 @@ func (s *ConfigurationService) CloneNoAuthToProject(configUUID string, newName s
|
||||
Notes: original.Notes,
|
||||
IsTemplate: false,
|
||||
ServerCount: original.ServerCount,
|
||||
PricelistID: original.PricelistID,
|
||||
OnlyInStock: original.OnlyInStock,
|
||||
}
|
||||
|
||||
if err := s.configRepo.Create(clone); err != nil {
|
||||
@@ -413,23 +370,6 @@ func (s *ConfigurationService) resolveProjectUUID(ownerUsername string, projectU
|
||||
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
|
||||
func (s *ConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
@@ -437,30 +377,8 @@ func (s *ConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configu
|
||||
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))
|
||||
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)
|
||||
if err != nil || metadata.CurrentPrice == nil {
|
||||
updatedItems[i] = item
|
||||
@@ -481,9 +399,6 @@ func (s *ConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configu
|
||||
}
|
||||
|
||||
config.TotalPrice = &total
|
||||
if latestPricelistID != nil {
|
||||
config.PricelistID = latestPricelistID
|
||||
}
|
||||
now := time.Now()
|
||||
config.PriceUpdatedAt = &now
|
||||
|
||||
@@ -517,32 +432,10 @@ func (s *ConfigurationService) RefreshPrices(uuid string, ownerUsername string)
|
||||
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
|
||||
updatedItems := make(models.ConfigItems, len(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
|
||||
if s.componentRepo == nil {
|
||||
updatedItems[i] = item
|
||||
continue
|
||||
}
|
||||
metadata, err := s.componentRepo.GetByLotName(item.LotName)
|
||||
if err != nil || metadata.CurrentPrice == nil {
|
||||
// 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
|
||||
if latestPricelistID != nil {
|
||||
config.PricelistID = latestPricelistID
|
||||
}
|
||||
|
||||
// Set price update timestamp
|
||||
now := time.Now()
|
||||
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||
@@ -27,7 +25,6 @@ func NewExportService(cfg config.ExportConfig, categoryRepo *repository.Category
|
||||
|
||||
type ExportData struct {
|
||||
Name string
|
||||
Article string
|
||||
Items []ExportItem
|
||||
Total float64
|
||||
Notes string
|
||||
@@ -43,21 +40,14 @@ type ExportItem struct {
|
||||
TotalPrice float64
|
||||
}
|
||||
|
||||
func (s *ExportService) ToCSV(w io.Writer, data *ExportData) 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)
|
||||
// Use semicolon as delimiter for Russian Excel locale
|
||||
csvWriter.Comma = ';'
|
||||
defer csvWriter.Flush()
|
||||
func (s *ExportService) ToCSV(data *ExportData) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
w := csv.NewWriter(&buf)
|
||||
|
||||
// Header
|
||||
headers := []string{"Артикул", "Описание", "Категория", "Количество", "Цена за единицу", "Сумма"}
|
||||
if err := csvWriter.Write(headers); err != nil {
|
||||
return fmt.Errorf("failed to write header: %w", err)
|
||||
if err := w.Write(headers); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get category hierarchy for sorting
|
||||
@@ -100,35 +90,21 @@ func (s *ExportService) ToCSV(w io.Writer, data *ExportData) error {
|
||||
item.Description,
|
||||
item.Category,
|
||||
fmt.Sprintf("%d", item.Quantity),
|
||||
strings.ReplaceAll(fmt.Sprintf("%.2f", item.UnitPrice), ".", ","),
|
||||
strings.ReplaceAll(fmt.Sprintf("%.2f", item.TotalPrice), ".", ","),
|
||||
fmt.Sprintf("%.2f", item.UnitPrice),
|
||||
fmt.Sprintf("%.2f", item.TotalPrice),
|
||||
}
|
||||
if err := csvWriter.Write(row); err != nil {
|
||||
return fmt.Errorf("failed to write row: %w", err)
|
||||
if err := w.Write(row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Total row
|
||||
totalStr := strings.ReplaceAll(fmt.Sprintf("%.2f", data.Total), ".", ",")
|
||||
if err := csvWriter.Write([]string{data.Article, "", "", "", "ИТОГО:", totalStr}); err != nil {
|
||||
return fmt.Errorf("failed to write total 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 *ExportData) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := s.ToCSV(&buf, data); err != nil {
|
||||
if err := w.Write([]string{"", "", "", "", "ИТОГО:", fmt.Sprintf("%.2f", data.Total)}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
|
||||
w.Flush()
|
||||
return buf.Bytes(), w.Error()
|
||||
}
|
||||
|
||||
func (s *ExportService) ConfigToExportData(config *models.Configuration, componentService *ComponentService) *ExportData {
|
||||
@@ -163,7 +139,6 @@ func (s *ExportService) ConfigToExportData(config *models.Configuration, compone
|
||||
|
||||
return &ExportData{
|
||||
Name: config.Name,
|
||||
Article: "",
|
||||
Items: items,
|
||||
Total: total,
|
||||
Notes: config.Notes,
|
||||
|
||||
@@ -1,343 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||
)
|
||||
|
||||
|
||||
func TestToCSV_UTF8BOM(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil)
|
||||
|
||||
data := &ExportData{
|
||||
Name: "Test",
|
||||
Items: []ExportItem{
|
||||
{
|
||||
LotName: "LOT-001",
|
||||
Description: "Test Item",
|
||||
Category: "CAT",
|
||||
Quantity: 1,
|
||||
UnitPrice: 100.0,
|
||||
TotalPrice: 100.0,
|
||||
},
|
||||
},
|
||||
Total: 100.0,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := svc.ToCSV(&buf, data); err != nil {
|
||||
t.Fatalf("ToCSV failed: %v", err)
|
||||
}
|
||||
|
||||
csvBytes := buf.Bytes()
|
||||
if len(csvBytes) < 3 {
|
||||
t.Fatalf("CSV too short to contain BOM")
|
||||
}
|
||||
|
||||
// Check UTF-8 BOM: 0xEF 0xBB 0xBF
|
||||
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
||||
actualBOM := csvBytes[:3]
|
||||
|
||||
if bytes.Compare(actualBOM, expectedBOM) != 0 {
|
||||
t.Errorf("UTF-8 BOM mismatch. Expected %v, got %v", expectedBOM, actualBOM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCSV_SemicolonDelimiter(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil)
|
||||
|
||||
data := &ExportData{
|
||||
Name: "Test",
|
||||
Items: []ExportItem{
|
||||
{
|
||||
LotName: "LOT-001",
|
||||
Description: "Test Item",
|
||||
Category: "CAT",
|
||||
Quantity: 2,
|
||||
UnitPrice: 100.50,
|
||||
TotalPrice: 201.00,
|
||||
},
|
||||
},
|
||||
Total: 201.00,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := svc.ToCSV(&buf, data); err != nil {
|
||||
t.Fatalf("ToCSV failed: %v", err)
|
||||
}
|
||||
|
||||
// Skip BOM and read CSV with semicolon delimiter
|
||||
csvBytes := buf.Bytes()
|
||||
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
||||
reader.Comma = ';'
|
||||
|
||||
// Read header
|
||||
header, err := reader.Read()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read header: %v", err)
|
||||
}
|
||||
|
||||
if len(header) != 6 {
|
||||
t.Errorf("Expected 6 columns, got %d", len(header))
|
||||
}
|
||||
|
||||
expectedHeader := []string{"Артикул", "Описание", "Категория", "Количество", "Цена за единицу", "Сумма"}
|
||||
for i, col := range expectedHeader {
|
||||
if i < len(header) && header[i] != col {
|
||||
t.Errorf("Column %d: expected %q, got %q", i, col, header[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Read item row
|
||||
itemRow, err := reader.Read()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read item row: %v", err)
|
||||
}
|
||||
|
||||
if itemRow[0] != "LOT-001" {
|
||||
t.Errorf("Lot name mismatch: expected LOT-001, got %s", itemRow[0])
|
||||
}
|
||||
|
||||
if itemRow[3] != "2" {
|
||||
t.Errorf("Quantity mismatch: expected 2, got %s", itemRow[3])
|
||||
}
|
||||
|
||||
if itemRow[4] != "100,50" {
|
||||
t.Errorf("Unit price mismatch: expected 100,50, got %s", itemRow[4])
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCSV_TotalRow(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil)
|
||||
|
||||
data := &ExportData{
|
||||
Name: "Test",
|
||||
Items: []ExportItem{
|
||||
{
|
||||
LotName: "LOT-001",
|
||||
Description: "Item 1",
|
||||
Category: "CAT",
|
||||
Quantity: 1,
|
||||
UnitPrice: 100.0,
|
||||
TotalPrice: 100.0,
|
||||
},
|
||||
{
|
||||
LotName: "LOT-002",
|
||||
Description: "Item 2",
|
||||
Category: "CAT",
|
||||
Quantity: 2,
|
||||
UnitPrice: 50.0,
|
||||
TotalPrice: 100.0,
|
||||
},
|
||||
},
|
||||
Total: 200.0,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := svc.ToCSV(&buf, data); err != nil {
|
||||
t.Fatalf("ToCSV failed: %v", err)
|
||||
}
|
||||
|
||||
csvBytes := buf.Bytes()
|
||||
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
||||
reader.Comma = ';'
|
||||
|
||||
// Skip header and item rows
|
||||
reader.Read()
|
||||
reader.Read()
|
||||
reader.Read()
|
||||
|
||||
// Read total row
|
||||
totalRow, err := reader.Read()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read total row: %v", err)
|
||||
}
|
||||
|
||||
// Total row should have "ИТОГО:" in position 4 and total value in position 5
|
||||
if totalRow[4] != "ИТОГО:" {
|
||||
t.Errorf("Expected 'ИТОГО:' in column 4, got %q", totalRow[4])
|
||||
}
|
||||
|
||||
if totalRow[5] != "200,00" {
|
||||
t.Errorf("Expected total 200,00, got %s", totalRow[5])
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCSV_CategorySorting(t *testing.T) {
|
||||
// Test category sorting without category repo (items maintain original order)
|
||||
svc := NewExportService(config.ExportConfig{}, nil)
|
||||
|
||||
data := &ExportData{
|
||||
Name: "Test",
|
||||
Items: []ExportItem{
|
||||
{
|
||||
LotName: "LOT-001",
|
||||
Category: "CAT-A",
|
||||
Quantity: 1,
|
||||
UnitPrice: 100.0,
|
||||
TotalPrice: 100.0,
|
||||
},
|
||||
{
|
||||
LotName: "LOT-002",
|
||||
Category: "CAT-C",
|
||||
Quantity: 1,
|
||||
UnitPrice: 100.0,
|
||||
TotalPrice: 100.0,
|
||||
},
|
||||
{
|
||||
LotName: "LOT-003",
|
||||
Category: "CAT-B",
|
||||
Quantity: 1,
|
||||
UnitPrice: 100.0,
|
||||
TotalPrice: 100.0,
|
||||
},
|
||||
},
|
||||
Total: 300.0,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := svc.ToCSV(&buf, data); err != nil {
|
||||
t.Fatalf("ToCSV failed: %v", err)
|
||||
}
|
||||
|
||||
csvBytes := buf.Bytes()
|
||||
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
||||
reader.Comma = ';'
|
||||
|
||||
// Skip header
|
||||
reader.Read()
|
||||
|
||||
// Without category repo, items maintain original order
|
||||
row1, _ := reader.Read()
|
||||
if row1[0] != "LOT-001" {
|
||||
t.Errorf("Expected LOT-001 first, got %s", row1[0])
|
||||
}
|
||||
|
||||
row2, _ := reader.Read()
|
||||
if row2[0] != "LOT-002" {
|
||||
t.Errorf("Expected LOT-002 second, got %s", row2[0])
|
||||
}
|
||||
|
||||
row3, _ := reader.Read()
|
||||
if row3[0] != "LOT-003" {
|
||||
t.Errorf("Expected LOT-003 third, got %s", row3[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCSV_EmptyData(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil)
|
||||
|
||||
data := &ExportData{
|
||||
Name: "Test",
|
||||
Items: []ExportItem{},
|
||||
Total: 0.0,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := svc.ToCSV(&buf, data); err != nil {
|
||||
t.Fatalf("ToCSV failed: %v", err)
|
||||
}
|
||||
|
||||
csvBytes := buf.Bytes()
|
||||
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
||||
reader.Comma = ';'
|
||||
|
||||
// Should have header and total row
|
||||
header, err := reader.Read()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read header: %v", err)
|
||||
}
|
||||
|
||||
if len(header) != 6 {
|
||||
t.Errorf("Expected 6 columns, got %d", len(header))
|
||||
}
|
||||
|
||||
totalRow, err := reader.Read()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read total row: %v", err)
|
||||
}
|
||||
|
||||
if totalRow[4] != "ИТОГО:" {
|
||||
t.Errorf("Expected ИТОГО: in total row, got %s", totalRow[4])
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCSVBytes_BackwardCompat(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil)
|
||||
|
||||
data := &ExportData{
|
||||
Name: "Test",
|
||||
Items: []ExportItem{
|
||||
{
|
||||
LotName: "LOT-001",
|
||||
Description: "Test Item",
|
||||
Category: "CAT",
|
||||
Quantity: 1,
|
||||
UnitPrice: 100.0,
|
||||
TotalPrice: 100.0,
|
||||
},
|
||||
},
|
||||
Total: 100.0,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
csvBytes, err := svc.ToCSVBytes(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ToCSVBytes failed: %v", err)
|
||||
}
|
||||
|
||||
if len(csvBytes) < 3 {
|
||||
t.Fatalf("CSV bytes too short")
|
||||
}
|
||||
|
||||
// Verify BOM is present
|
||||
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
||||
actualBOM := csvBytes[:3]
|
||||
if bytes.Compare(actualBOM, expectedBOM) != 0 {
|
||||
t.Errorf("UTF-8 BOM mismatch in ToCSVBytes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCSV_WriterError(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil)
|
||||
|
||||
data := &ExportData{
|
||||
Name: "Test",
|
||||
Items: []ExportItem{
|
||||
{
|
||||
LotName: "LOT-001",
|
||||
Description: "Test",
|
||||
Category: "CAT",
|
||||
Quantity: 1,
|
||||
UnitPrice: 100.0,
|
||||
TotalPrice: 100.0,
|
||||
},
|
||||
},
|
||||
Total: 100.0,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Use a failing writer
|
||||
failingWriter := &failingWriter{}
|
||||
|
||||
if err := svc.ToCSV(failingWriter, data); err == nil {
|
||||
t.Errorf("Expected error from failing writer, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// failingWriter always returns an error
|
||||
type failingWriter struct{}
|
||||
|
||||
func (fw *failingWriter) Write(p []byte) (int, error) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"time"
|
||||
|
||||
"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/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||
@@ -60,22 +59,6 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
|
||||
if err != nil {
|
||||
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()
|
||||
if req.ServerCount > 1 {
|
||||
@@ -93,11 +76,6 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
|
||||
Notes: req.Notes,
|
||||
IsTemplate: req.IsTemplate,
|
||||
ServerCount: req.ServerCount,
|
||||
ServerModel: req.ServerModel,
|
||||
SupportCode: req.SupportCode,
|
||||
Article: req.Article,
|
||||
PricelistID: pricelistID,
|
||||
OnlyInStock: req.OnlyInStock,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
@@ -146,30 +124,11 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
|
||||
return nil, ErrConfigForbidden
|
||||
}
|
||||
|
||||
projectUUID := localCfg.ProjectUUID
|
||||
if req.ProjectUUID != nil {
|
||||
projectUUID, err = s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
||||
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
|
||||
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()
|
||||
if req.ServerCount > 1 {
|
||||
total *= float64(req.ServerCount)
|
||||
@@ -191,11 +150,6 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
|
||||
localCfg.Notes = req.Notes
|
||||
localCfg.IsTemplate = req.IsTemplate
|
||||
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.SyncStatus = "pending"
|
||||
|
||||
@@ -207,19 +161,6 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
|
||||
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
|
||||
func (s *LocalConfigurationService) Delete(uuid string, ownerUsername string) error {
|
||||
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||
@@ -313,11 +254,6 @@ func (s *LocalConfigurationService) CloneToProject(configUUID string, ownerUsern
|
||||
Notes: original.Notes,
|
||||
IsTemplate: false,
|
||||
ServerCount: original.ServerCount,
|
||||
ServerModel: original.ServerModel,
|
||||
SupportCode: original.SupportCode,
|
||||
Article: original.Article,
|
||||
PricelistID: original.PricelistID,
|
||||
OnlyInStock: original.OnlyInStock,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
@@ -388,29 +324,23 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
||||
return nil, ErrConfigForbidden
|
||||
}
|
||||
|
||||
// Refresh local pricelists when online and use latest active/local pricelist for recalculation.
|
||||
if s.isOnline() {
|
||||
_ = s.syncService.SyncPricelistsIfNeeded()
|
||||
}
|
||||
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
|
||||
|
||||
// Update prices for all items from pricelist
|
||||
// Update prices for all items
|
||||
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
||||
for i, item := range localCfg.Items {
|
||||
if latestErr == nil && latestPricelist != nil {
|
||||
price, err := s.localDB.GetLocalPriceForLot(latestPricelist.ID, item.LotName)
|
||||
if err == nil && price > 0 {
|
||||
updatedItems[i] = localdb.LocalConfigItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: price,
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Get current component price from local cache
|
||||
component, err := s.localDB.GetLocalComponent(item.LotName)
|
||||
if err != nil || component.CurrentPrice == nil {
|
||||
// Keep original item if component not found or no price available
|
||||
updatedItems[i] = item
|
||||
continue
|
||||
}
|
||||
|
||||
// Keep original item if price not found in pricelist
|
||||
updatedItems[i] = item
|
||||
// Update item with current price from local cache
|
||||
updatedItems[i] = localdb.LocalConfigItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: *component.CurrentPrice,
|
||||
}
|
||||
}
|
||||
|
||||
// Update configuration
|
||||
@@ -423,9 +353,6 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
||||
}
|
||||
|
||||
localCfg.TotalPrice = &total
|
||||
if latestErr == nil && latestPricelist != nil {
|
||||
localCfg.PricelistID = &latestPricelist.ServerID
|
||||
}
|
||||
|
||||
// Set price update timestamp and mark for sync
|
||||
now := time.Now()
|
||||
@@ -459,30 +386,11 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
projectUUID := localCfg.ProjectUUID
|
||||
if req.ProjectUUID != nil {
|
||||
projectUUID, err = s.resolveProjectUUID(localCfg.OriginalUsername, req.ProjectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
||||
projectUUID, err := s.resolveProjectUUID(localCfg.OriginalUsername, req.ProjectUUID)
|
||||
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()
|
||||
if req.ServerCount > 1 {
|
||||
total *= float64(req.ServerCount)
|
||||
@@ -503,11 +411,6 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
|
||||
localCfg.Notes = req.Notes
|
||||
localCfg.IsTemplate = req.IsTemplate
|
||||
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.SyncStatus = "pending"
|
||||
|
||||
@@ -599,8 +502,6 @@ func (s *LocalConfigurationService) CloneNoAuthToProject(configUUID string, newN
|
||||
Notes: original.Notes,
|
||||
IsTemplate: false,
|
||||
ServerCount: original.ServerCount,
|
||||
PricelistID: original.PricelistID,
|
||||
OnlyInStock: original.OnlyInStock,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
@@ -650,6 +551,26 @@ func (s *LocalConfigurationService) ListAll(page, perPage int) ([]models.Configu
|
||||
|
||||
// ListAllWithStatus returns configurations filtered by status: active|archived|all.
|
||||
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
|
||||
if page < 1 {
|
||||
page = 1
|
||||
@@ -658,15 +579,17 @@ func (s *LocalConfigurationService) ListAllWithStatus(page, perPage int, status
|
||||
perPage = 20
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
localConfigs, total, err := s.localDB.ListConfigurationsWithFilters(status, search, offset, perPage)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
|
||||
start := offset
|
||||
if start > len(configs) {
|
||||
start = len(configs)
|
||||
}
|
||||
configs := make([]models.Configuration, 0, len(localConfigs))
|
||||
for _, lc := range localConfigs {
|
||||
configs = append(configs, *localdb.LocalToConfiguration(&lc))
|
||||
end := start + perPage
|
||||
if end > len(configs) {
|
||||
end = len(configs)
|
||||
}
|
||||
return configs, total, nil
|
||||
|
||||
return configs[start:end], total, nil
|
||||
}
|
||||
|
||||
// ListTemplates returns all template configurations
|
||||
@@ -717,28 +640,23 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
if s.isOnline() {
|
||||
_ = s.syncService.SyncPricelistsIfNeeded()
|
||||
}
|
||||
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
|
||||
|
||||
// Update prices for all items from pricelist
|
||||
// Update prices for all items
|
||||
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
||||
for i, item := range localCfg.Items {
|
||||
if latestErr == nil && latestPricelist != nil {
|
||||
price, err := s.localDB.GetLocalPriceForLot(latestPricelist.ID, item.LotName)
|
||||
if err == nil && price > 0 {
|
||||
updatedItems[i] = localdb.LocalConfigItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: price,
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Get current component price from local cache
|
||||
component, err := s.localDB.GetLocalComponent(item.LotName)
|
||||
if err != nil || component.CurrentPrice == nil {
|
||||
// Keep original item if component not found or no price available
|
||||
updatedItems[i] = item
|
||||
continue
|
||||
}
|
||||
|
||||
// Keep original item if price not found in pricelist
|
||||
updatedItems[i] = item
|
||||
// Update item with current price from local cache
|
||||
updatedItems[i] = localdb.LocalConfigItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: *component.CurrentPrice,
|
||||
}
|
||||
}
|
||||
|
||||
// Update configuration
|
||||
@@ -751,9 +669,6 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
|
||||
}
|
||||
|
||||
localCfg.TotalPrice = &total
|
||||
if latestErr == nil && latestPricelist != nil {
|
||||
localCfg.PricelistID = &latestPricelist.ServerID
|
||||
}
|
||||
|
||||
// Set price update timestamp and mark for sync
|
||||
now := time.Now()
|
||||
@@ -900,9 +815,6 @@ func (s *LocalConfigurationService) createWithVersion(localCfg *localdb.LocalCon
|
||||
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "create", version, createdBy); err != nil {
|
||||
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
|
||||
})
|
||||
@@ -942,9 +854,6 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
|
||||
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, operation, version, createdBy); err != nil {
|
||||
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
|
||||
})
|
||||
@@ -1049,8 +958,6 @@ func (s *LocalConfigurationService) rollbackToVersion(configurationUUID string,
|
||||
current.Notes = rollbackData.Notes
|
||||
current.IsTemplate = rollbackData.IsTemplate
|
||||
current.ServerCount = rollbackData.ServerCount
|
||||
current.PricelistID = rollbackData.PricelistID
|
||||
current.OnlyInStock = rollbackData.OnlyInStock
|
||||
current.PriceUpdatedAt = rollbackData.PriceUpdatedAt
|
||||
current.UpdatedAt = time.Now()
|
||||
current.SyncStatus = "pending"
|
||||
@@ -1108,9 +1015,6 @@ func (s *LocalConfigurationService) rollbackToVersion(configurationUUID string,
|
||||
if err := s.enqueueConfigurationPendingChangeTx(tx, ¤t, "rollback", version, userID); err != nil {
|
||||
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
|
||||
})
|
||||
@@ -1134,7 +1038,6 @@ func (s *LocalConfigurationService) enqueueConfigurationPendingChangeTx(
|
||||
IdempotencyKey: fmt.Sprintf("%s:v%d:%s", localCfg.UUID, version.VersionNo, operation),
|
||||
ConfigurationUUID: localCfg.UUID,
|
||||
ProjectUUID: localCfg.ProjectUUID,
|
||||
PricelistID: localCfg.PricelistID,
|
||||
Operation: operation,
|
||||
CurrentVersionID: version.ID,
|
||||
CurrentVersionNo: version.VersionNo,
|
||||
@@ -1168,21 +1071,6 @@ func (s *LocalConfigurationService) decodeConfigurationSnapshot(data string) (*l
|
||||
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 {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
@@ -1228,25 +1116,3 @@ func (s *LocalConfigurationService) resolveProjectUUID(ownerUsername string, pro
|
||||
|
||||
return &project.UUID, nil
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) resolvePricelistID(pricelistID *uint) (*uint, error) {
|
||||
if pricelistID != nil && *pricelistID > 0 {
|
||||
if _, err := s.localDB.GetLocalPricelistByServerID(*pricelistID); err == nil {
|
||||
return pricelistID, nil
|
||||
}
|
||||
if s.isOnline() {
|
||||
if _, err := s.syncService.SyncPricelists(); err == nil {
|
||||
if _, err := s.localDB.GetLocalPricelistByServerID(*pricelistID); err == nil {
|
||||
return pricelistID, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("pricelist %d not available locally", *pricelistID)
|
||||
}
|
||||
|
||||
latest, err := s.localDB.GetLatestLocalPricelist()
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &latest.ServerID, nil
|
||||
}
|
||||
|
||||
@@ -185,48 +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",
|
||||
Name: "Keep Project",
|
||||
IsActive: true,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
SyncStatus: "synced",
|
||||
}
|
||||
if err := local.SaveProject(project); err != nil {
|
||||
t.Fatalf("save project: %v", err)
|
||||
}
|
||||
|
||||
created, err := service.Create("tester", &CreateConfigRequest{
|
||||
Name: "cfg",
|
||||
ProjectUUID: &project.UUID,
|
||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 100}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
if created.ProjectUUID == nil || *created.ProjectUUID != project.UUID {
|
||||
t.Fatalf("expected created config project_uuid=%s", project.UUID)
|
||||
}
|
||||
|
||||
updated, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||
Name: "cfg-updated",
|
||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 100}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update config without project_uuid: %v", err)
|
||||
}
|
||||
if updated.ProjectUUID == nil || *updated.ProjectUUID != project.UUID {
|
||||
t.Fatalf("expected project_uuid to stay %s after update, got %+v", project.UUID, updated.ProjectUUID)
|
||||
}
|
||||
}
|
||||
|
||||
func newLocalConfigServiceForTest(t *testing.T) (*LocalConfigurationService, *localdb.LocalDB) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
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"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -29,13 +28,11 @@ func NewProjectService(localDB *localdb.LocalDB) *ProjectService {
|
||||
}
|
||||
|
||||
type CreateProjectRequest struct {
|
||||
Name string `json:"name"`
|
||||
TrackerURL string `json:"tracker_url"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type UpdateProjectRequest struct {
|
||||
Name string `json:"name"`
|
||||
TrackerURL *string `json:"tracker_url,omitempty"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type ProjectConfigurationsResult struct {
|
||||
@@ -55,7 +52,6 @@ func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest)
|
||||
UUID: uuid.NewString(),
|
||||
OwnerUsername: ownerUsername,
|
||||
Name: name,
|
||||
TrackerURL: normalizeProjectTrackerURL(name, req.TrackerURL),
|
||||
IsActive: true,
|
||||
IsSystem: false,
|
||||
CreatedAt: now,
|
||||
@@ -76,6 +72,9 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
|
||||
if err != nil {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
if localProject.OwnerUsername != ownerUsername {
|
||||
return nil, ErrProjectForbidden
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
@@ -83,11 +82,6 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
|
||||
}
|
||||
|
||||
localProject.Name = name
|
||||
if req.TrackerURL != nil {
|
||||
localProject.TrackerURL = normalizeProjectTrackerURL(name, *req.TrackerURL)
|
||||
} else if strings.TrimSpace(localProject.TrackerURL) == "" {
|
||||
localProject.TrackerURL = normalizeProjectTrackerURL(name, "")
|
||||
}
|
||||
localProject.UpdatedAt = time.Now()
|
||||
localProject.SyncStatus = "pending"
|
||||
if err := s.localDB.SaveProject(localProject); err != nil {
|
||||
@@ -113,6 +107,9 @@ func (s *ProjectService) setProjectActive(projectUUID, ownerUsername string, isA
|
||||
if err := tx.Where("uuid = ?", projectUUID).First(&project).Error; err != nil {
|
||||
return ErrProjectNotFound
|
||||
}
|
||||
if project.OwnerUsername != ownerUsername {
|
||||
return ErrProjectForbidden
|
||||
}
|
||||
if project.IsActive == isActive {
|
||||
return nil
|
||||
}
|
||||
@@ -263,20 +260,6 @@ func (s *ProjectService) ResolveProjectUUID(ownerUsername string, projectUUID *s
|
||||
return &resolved, nil
|
||||
}
|
||||
|
||||
func normalizeProjectTrackerURL(projectCode, trackerURL string) string {
|
||||
trimmedURL := strings.TrimSpace(trackerURL)
|
||||
if trimmedURL != "" {
|
||||
return trimmedURL
|
||||
}
|
||||
|
||||
trimmedCode := strings.TrimSpace(projectCode)
|
||||
if trimmedCode == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return "https://tracker.yandex.ru/" + url.PathEscape(trimmedCode)
|
||||
}
|
||||
|
||||
func (s *ProjectService) enqueueProjectPendingChange(project *localdb.LocalProject, operation string) error {
|
||||
return s.enqueueProjectPendingChangeTx(s.localDB.DB(), project, operation)
|
||||
}
|
||||
|
||||
@@ -2,13 +2,10 @@ package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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/services/pricing"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -20,41 +17,21 @@ var (
|
||||
type QuoteService struct {
|
||||
componentRepo *repository.ComponentRepository
|
||||
statsRepo *repository.StatsRepository
|
||||
pricelistRepo *repository.PricelistRepository
|
||||
localDB *localdb.LocalDB
|
||||
pricingService priceResolver
|
||||
cacheMu sync.RWMutex
|
||||
priceCache map[string]cachedLotPrice
|
||||
cacheTTL time.Duration
|
||||
}
|
||||
|
||||
type priceResolver interface {
|
||||
GetEffectivePrice(lotName string) (*float64, error)
|
||||
pricingService *pricing.Service
|
||||
}
|
||||
|
||||
func NewQuoteService(
|
||||
componentRepo *repository.ComponentRepository,
|
||||
statsRepo *repository.StatsRepository,
|
||||
pricelistRepo *repository.PricelistRepository,
|
||||
localDB *localdb.LocalDB,
|
||||
pricingService priceResolver,
|
||||
pricingService *pricing.Service,
|
||||
) *QuoteService {
|
||||
return &QuoteService{
|
||||
componentRepo: componentRepo,
|
||||
statsRepo: statsRepo,
|
||||
pricelistRepo: pricelistRepo,
|
||||
localDB: localDB,
|
||||
pricingService: pricingService,
|
||||
priceCache: make(map[string]cachedLotPrice, 4096),
|
||||
cacheTTL: 10 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
type cachedLotPrice struct {
|
||||
price *float64
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type QuoteItem struct {
|
||||
LotName string `json:"lot_name"`
|
||||
Quantity int `json:"quantity"`
|
||||
@@ -78,105 +55,14 @@ type QuoteRequest struct {
|
||||
LotName string `json:"lot_name"`
|
||||
Quantity int `json:"quantity"`
|
||||
} `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) {
|
||||
if len(req.Items) == 0 {
|
||||
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 {
|
||||
return nil, errors.New("quote calculation not available")
|
||||
return nil, errors.New("offline mode: quote calculation not available")
|
||||
}
|
||||
|
||||
result := &QuoteValidationResult{
|
||||
@@ -244,258 +130,6 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
|
||||
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
|
||||
func (s *QuoteService) RecordUsage(items []models.ConfigItem) error {
|
||||
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"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||
@@ -51,13 +49,6 @@ type SyncStatus struct {
|
||||
NeedsSync bool `json:"needs_sync"`
|
||||
}
|
||||
|
||||
type UserSyncStatus struct {
|
||||
Username string `json:"username"`
|
||||
LastSyncAt time.Time `json:"last_sync_at"`
|
||||
AppVersion string `json:"app_version,omitempty"`
|
||||
IsOnline bool `json:"is_online"`
|
||||
}
|
||||
|
||||
// ConfigImportResult represents server->local configuration import stats.
|
||||
type ConfigImportResult struct {
|
||||
Imported int `json:"imported"`
|
||||
@@ -65,13 +56,6 @@ type ConfigImportResult struct {
|
||||
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.
|
||||
// It carries version metadata so sync can push the latest snapshot and prepare for conflict resolution.
|
||||
type ConfigurationChangePayload struct {
|
||||
@@ -79,7 +63,6 @@ type ConfigurationChangePayload struct {
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
ConfigurationUUID string `json:"configuration_uuid"`
|
||||
ProjectUUID *string `json:"project_uuid,omitempty"`
|
||||
PricelistID *uint `json:"pricelist_id,omitempty"`
|
||||
Operation string `json:"operation"` // create/update/rollback/deactivate/reactivate/delete
|
||||
CurrentVersionID string `json:"current_version_id,omitempty"`
|
||||
CurrentVersionNo int `json:"current_version_no,omitempty"`
|
||||
@@ -161,78 +144,6 @@ func (s *Service) ImportConfigurationsToLocal() (*ConfigImportResult, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ImportProjectsToLocal imports projects from MariaDB into local SQLite.
|
||||
// Existing local projects with pending local changes are skipped to avoid data loss.
|
||||
func (s *Service) ImportProjectsToLocal() (*ProjectImportResult, error) {
|
||||
mariaDB, err := s.getDB()
|
||||
if err != nil {
|
||||
return nil, ErrOffline
|
||||
}
|
||||
|
||||
projectRepo := repository.NewProjectRepository(mariaDB)
|
||||
result := &ProjectImportResult{}
|
||||
|
||||
offset := 0
|
||||
const limit = 200
|
||||
for {
|
||||
serverProjects, _, err := projectRepo.List(offset, limit, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing server projects: %w", err)
|
||||
}
|
||||
if len(serverProjects) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for i := range serverProjects {
|
||||
project := serverProjects[i]
|
||||
|
||||
existing, getErr := s.localDB.GetProjectByUUID(project.UUID)
|
||||
if getErr != nil && !errors.Is(getErr, gorm.ErrRecordNotFound) {
|
||||
return nil, fmt.Errorf("getting local project %s: %w", project.UUID, getErr)
|
||||
}
|
||||
|
||||
if existing != nil && getErr == nil {
|
||||
// Keep unsynced local changes intact.
|
||||
if existing.SyncStatus == "pending" {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
existing.OwnerUsername = project.OwnerUsername
|
||||
existing.Name = project.Name
|
||||
existing.TrackerURL = project.TrackerURL
|
||||
existing.IsActive = project.IsActive
|
||||
existing.IsSystem = project.IsSystem
|
||||
existing.CreatedAt = project.CreatedAt
|
||||
existing.UpdatedAt = project.UpdatedAt
|
||||
serverID := project.ID
|
||||
existing.ServerID = &serverID
|
||||
existing.SyncStatus = "synced"
|
||||
existing.SyncedAt = &now
|
||||
|
||||
if err := s.localDB.SaveProject(existing); err != nil {
|
||||
return nil, fmt.Errorf("saving local project %s: %w", project.UUID, err)
|
||||
}
|
||||
result.Updated++
|
||||
continue
|
||||
}
|
||||
|
||||
localProject := localdb.ProjectToLocal(&project)
|
||||
localProject.SyncStatus = "synced"
|
||||
localProject.SyncedAt = &now
|
||||
if err := s.localDB.SaveProject(localProject); err != nil {
|
||||
return nil, fmt.Errorf("saving local project %s: %w", project.UUID, err)
|
||||
}
|
||||
result.Imported++
|
||||
}
|
||||
|
||||
offset += len(serverProjects)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetStatus returns the current sync status
|
||||
func (s *Service) GetStatus() (*SyncStatus, error) {
|
||||
lastSync := s.localDB.GetLastSyncTime()
|
||||
@@ -292,28 +203,21 @@ func (s *Service) NeedSync() (bool, error) {
|
||||
}
|
||||
|
||||
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
||||
sources := []models.PricelistSource{
|
||||
models.PricelistSourceEstimate,
|
||||
models.PricelistSourceWarehouse,
|
||||
models.PricelistSourceCompetitor,
|
||||
latestServer, err := pricelistRepo.GetLatestActive()
|
||||
if err != nil {
|
||||
// If no pricelists on server, no need to sync
|
||||
return false, nil
|
||||
}
|
||||
for _, source := range sources {
|
||||
latestServer, err := pricelistRepo.GetLatestActiveBySource(string(source))
|
||||
if err != nil {
|
||||
// No active pricelist for this source yet.
|
||||
continue
|
||||
}
|
||||
|
||||
latestLocal, err := s.localDB.GetLatestLocalPricelistBySource(string(source))
|
||||
if err != nil {
|
||||
// No local pricelist for an existing source on server.
|
||||
return true, nil
|
||||
}
|
||||
latestLocal, err := s.localDB.GetLatestLocalPricelist()
|
||||
if err != nil {
|
||||
// No local pricelists, need to sync
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// If server has newer pricelist for this source, need sync.
|
||||
if latestServer.ID != latestLocal.ServerID {
|
||||
return true, nil
|
||||
}
|
||||
// If server has newer pricelist, need sync
|
||||
if latestServer.ID != latestLocal.ServerID {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
@@ -322,9 +226,6 @@ func (s *Service) NeedSync() (bool, error) {
|
||||
// SyncPricelists synchronizes all active pricelists from server to local SQLite
|
||||
func (s *Service) SyncPricelists() (int, error) {
|
||||
slog.Info("starting pricelist sync")
|
||||
if _, err := s.EnsureReadinessForSync(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Get database connection
|
||||
mariaDB, err := s.getDB()
|
||||
@@ -340,23 +241,25 @@ func (s *Service) SyncPricelists() (int, error) {
|
||||
if err != nil {
|
||||
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
|
||||
var latestLocalID uint
|
||||
var latestServerID uint
|
||||
for _, pl := range serverPricelists {
|
||||
// Check if pricelist already exists locally
|
||||
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
|
||||
if existing != nil {
|
||||
// Already synced, track latest by server ID
|
||||
if pl.ID > latestServerID {
|
||||
latestServerID = pl.ID
|
||||
latestLocalID = existing.ID
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Create local pricelist
|
||||
localPL := &localdb.LocalPricelist{
|
||||
ServerID: pl.ID,
|
||||
Source: pl.Source,
|
||||
Version: pl.Version,
|
||||
Name: pl.Notification, // Using notification as name
|
||||
CreatedAt: pl.CreatedAt,
|
||||
@@ -378,270 +281,30 @@ func (s *Service) SyncPricelists() (int, error) {
|
||||
slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount)
|
||||
}
|
||||
|
||||
if pl.ID > latestServerID {
|
||||
latestServerID = pl.ID
|
||||
latestLocalID = localPL.ID
|
||||
}
|
||||
synced++
|
||||
}
|
||||
|
||||
removed, err := s.localDB.DeleteUnusedLocalPricelistsMissingOnServer(serverPricelistIDs)
|
||||
if err != nil {
|
||||
slog.Warn("failed to cleanup stale local pricelists", "error", err)
|
||||
} else if removed > 0 {
|
||||
slog.Info("deleted stale local pricelists", "deleted", removed)
|
||||
// Update component prices from latest pricelist
|
||||
if latestLocalID > 0 {
|
||||
updated, err := s.localDB.UpdateComponentPricesFromPricelist(latestLocalID)
|
||||
if err != nil {
|
||||
slog.Warn("failed to update component prices from pricelist", "error", err)
|
||||
} else {
|
||||
slog.Info("updated component prices from latest pricelist", "updated", updated)
|
||||
}
|
||||
}
|
||||
|
||||
// Backfill lot_category for used pricelists (older local caches may miss the column values).
|
||||
s.backfillUsedPricelistItemCategories(pricelistRepo, serverPricelistIDs)
|
||||
|
||||
// Update last sync time
|
||||
s.localDB.SetLastSyncTime(time.Now())
|
||||
s.RecordSyncHeartbeat()
|
||||
|
||||
slog.Info("pricelist sync completed", "synced", synced, "total", len(serverPricelists))
|
||||
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
|
||||
func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
|
||||
// Get local pricelist
|
||||
@@ -675,7 +338,11 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
|
||||
// Convert and save locally
|
||||
localItems := make([]localdb.LocalPricelistItem, len(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 {
|
||||
@@ -751,10 +418,6 @@ func (s *Service) SyncPricelistsIfNeeded() error {
|
||||
|
||||
// PushPendingChanges pushes all pending changes to the server
|
||||
func (s *Service) PushPendingChanges() (int, error) {
|
||||
if _, err := s.EnsureReadinessForSync(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
removed, err := s.localDB.PurgeOrphanConfigurationPendingChanges()
|
||||
if err != nil {
|
||||
slog.Warn("failed to purge orphan configuration pending changes", "error", err)
|
||||
@@ -849,8 +512,20 @@ func (s *Service) pushProjectChange(change *localdb.PendingChange) error {
|
||||
project := payload.Snapshot
|
||||
project.UUID = payload.ProjectUUID
|
||||
|
||||
if err := projectRepo.UpsertByUUID(&project); err != nil {
|
||||
return fmt.Errorf("upsert project on server: %w", err)
|
||||
serverProject, err := projectRepo.GetByUUID(project.UUID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
if createErr := projectRepo.Create(&project); createErr != nil {
|
||||
return fmt.Errorf("create project on server: %w", createErr)
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("get project on server: %w", err)
|
||||
}
|
||||
} else {
|
||||
project.ID = serverProject.ID
|
||||
if updateErr := projectRepo.Update(&project); updateErr != nil {
|
||||
return fmt.Errorf("update project on server: %w", updateErr)
|
||||
}
|
||||
}
|
||||
|
||||
localProject, localErr := s.localDB.GetProjectByUUID(project.UUID)
|
||||
@@ -935,9 +610,6 @@ func (s *Service) pushConfigurationCreate(change *localdb.PendingChange) error {
|
||||
if err := s.ensureConfigurationProject(mariaDB, &cfg); err != nil {
|
||||
return fmt.Errorf("resolve configuration project: %w", err)
|
||||
}
|
||||
if err := s.ensureConfigurationPricelist(mariaDB, &cfg); err != nil {
|
||||
return fmt.Errorf("resolve configuration pricelist: %w", err)
|
||||
}
|
||||
|
||||
// Create on server
|
||||
if err := configRepo.Create(&cfg); err != nil {
|
||||
@@ -996,9 +668,6 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
|
||||
if err := s.ensureConfigurationProject(mariaDB, &cfg); err != nil {
|
||||
return fmt.Errorf("resolve configuration project: %w", err)
|
||||
}
|
||||
if err := s.ensureConfigurationPricelist(mariaDB, &cfg); err != nil {
|
||||
return fmt.Errorf("resolve configuration pricelist: %w", err)
|
||||
}
|
||||
|
||||
// Ensure we have a server ID before updating
|
||||
// If the payload doesn't have ID, get it from local configuration
|
||||
@@ -1009,34 +678,15 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
|
||||
}
|
||||
|
||||
if localCfg.ServerID == nil {
|
||||
// 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, getErr := configRepo.GetByUUID(cfg.UUID)
|
||||
if getErr != nil {
|
||||
if !errors.Is(getErr, gorm.ErrRecordNotFound) {
|
||||
return fmt.Errorf("loading configuration from server: %w", getErr)
|
||||
}
|
||||
if createErr := configRepo.Create(&cfg); createErr != nil {
|
||||
// Idempotency fallback: configuration may have been created concurrently.
|
||||
existing, existingErr := configRepo.GetByUUID(cfg.UUID)
|
||||
if existingErr != nil {
|
||||
return fmt.Errorf("creating missing configuration on server: %w", createErr)
|
||||
}
|
||||
cfg.ID = existing.ID
|
||||
}
|
||||
if cfg.ID == 0 {
|
||||
existing, existingErr := configRepo.GetByUUID(cfg.UUID)
|
||||
if existingErr != nil {
|
||||
return fmt.Errorf("loading created configuration from server: %w", existingErr)
|
||||
}
|
||||
cfg.ID = existing.ID
|
||||
}
|
||||
} else {
|
||||
cfg.ID = serverCfg.ID
|
||||
// Configuration hasn't been synced yet, try to find it on server by UUID
|
||||
serverCfg, err := configRepo.GetByUUID(cfg.UUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("configuration not yet synced to server: %w", err)
|
||||
}
|
||||
cfg.ID = serverCfg.ID
|
||||
|
||||
// Update local with server ID
|
||||
serverID := cfg.ID
|
||||
serverID := serverCfg.ID
|
||||
localCfg.ServerID = &serverID
|
||||
s.localDB.SaveConfiguration(localCfg)
|
||||
} else {
|
||||
@@ -1112,7 +762,7 @@ func (s *Service) ensureConfigurationProject(mariaDB *gorm.DB, cfg *models.Confi
|
||||
if modelProject.OwnerUsername == "" {
|
||||
modelProject.OwnerUsername = cfg.OwnerUsername
|
||||
}
|
||||
if createErr := projectRepo.UpsertByUUID(modelProject); createErr != nil {
|
||||
if createErr := projectRepo.Create(modelProject); createErr != nil {
|
||||
return createErr
|
||||
}
|
||||
if modelProject.ID > 0 {
|
||||
@@ -1151,29 +801,6 @@ func (s *Service) ensureConfigurationProject(mariaDB *gorm.DB, cfg *models.Confi
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ensureConfigurationPricelist(mariaDB *gorm.DB, cfg *models.Configuration) error {
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("configuration is nil")
|
||||
}
|
||||
|
||||
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
||||
|
||||
if cfg.PricelistID != nil && *cfg.PricelistID > 0 {
|
||||
if _, err := pricelistRepo.GetByID(*cfg.PricelistID); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
latest, err := pricelistRepo.GetLatestActive()
|
||||
if err != nil {
|
||||
cfg.PricelistID = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg.PricelistID = &latest.ID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) pushConfigurationRollback(change *localdb.PendingChange) error {
|
||||
// Last-write-wins for now: rollback is pushed as an update with rollback metadata.
|
||||
return s.pushConfigurationUpdate(change)
|
||||
@@ -1221,7 +848,6 @@ func (s *Service) resolveConfigurationPayloadForPush(change *localdb.PendingChan
|
||||
if currentVersionNo > 0 {
|
||||
payload.CurrentVersionNo = currentVersionNo
|
||||
}
|
||||
payload.PricelistID = currentCfg.PricelistID
|
||||
}
|
||||
|
||||
isStale := false
|
||||
@@ -1259,7 +885,6 @@ func decodeConfigurationChangePayload(change *localdb.PendingChange) (Configurat
|
||||
IdempotencyKey: fmt.Sprintf("%s:%s:legacy", cfg.UUID, change.Operation),
|
||||
ConfigurationUUID: cfg.UUID,
|
||||
ProjectUUID: cfg.ProjectUUID,
|
||||
PricelistID: cfg.PricelistID,
|
||||
Operation: change.Operation,
|
||||
ConflictPolicy: "last_write_wins",
|
||||
Snapshot: cfg,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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: "Project v1"})
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
if _, err := projectService.Update(project.UUID, "tester", &services.UpdateProjectRequest{Name: "Project v2"}); err != nil {
|
||||
t.Fatalf("update project: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := configService.Create("tester", &services.CreateConfigRequest{
|
||||
Name: "Cfg linked",
|
||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}},
|
||||
ServerCount: 1,
|
||||
ProjectUUID: &project.UUID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
|
||||
if _, err := pushService.PushPendingChanges(); err != nil {
|
||||
t.Fatalf("push pending changes: %v", err)
|
||||
}
|
||||
|
||||
var serverProject models.Project
|
||||
if err := serverDB.Where("uuid = ?", project.UUID).First(&serverProject).Error; err != nil {
|
||||
t.Fatalf("project not pushed to server: %v", err)
|
||||
}
|
||||
if serverProject.Name != "Project v2" {
|
||||
t.Fatalf("expected latest project name, got %q", serverProject.Name)
|
||||
}
|
||||
|
||||
var serverCfg models.Configuration
|
||||
if err := serverDB.Where("uuid = ?", cfg.UUID).First(&serverCfg).Error; err != nil {
|
||||
t.Fatalf("configuration not pushed to server: %v", err)
|
||||
}
|
||||
if serverCfg.ProjectUUID == nil || *serverCfg.ProjectUUID != project.UUID {
|
||||
t.Fatalf("expected project_uuid=%s on pushed config, got %v", project.UUID, serverCfg.ProjectUUID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushPendingChangesSkipsStaleUpdateAndAppliesLatest(t *testing.T) {
|
||||
local := newLocalDBForSyncTest(t)
|
||||
serverDB := newServerDBForSyncTest(t)
|
||||
@@ -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 {
|
||||
t.Helper()
|
||||
localPath := filepath.Join(t.TempDir(), "local.db")
|
||||
@@ -325,7 +226,6 @@ CREATE TABLE qt_projects (
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
owner_username TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
tracker_url TEXT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
is_system INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME,
|
||||
@@ -348,14 +248,6 @@ CREATE TABLE qt_configurations (
|
||||
notes TEXT NULL,
|
||||
is_template INTEGER NOT NULL DEFAULT 0,
|
||||
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,
|
||||
created_at DATETIME
|
||||
);`).Error; err != nil {
|
||||
|
||||
@@ -71,15 +71,6 @@ func (w *Worker) runSync() {
|
||||
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
|
||||
pushed, err := w.service.PushPendingChanges()
|
||||
if err != nil {
|
||||
@@ -92,11 +83,7 @@ func (w *Worker) runSync() {
|
||||
err = w.service.SyncPricelistsIfNeeded()
|
||||
if err != nil {
|
||||
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")
|
||||
}
|
||||
|
||||
38
memory.md
38
memory.md
@@ -1,38 +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).
|
||||
@@ -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)
|
||||
);
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE stock_log
|
||||
CHANGE COLUMN lot partnumber VARCHAR(255) NOT NULL;
|
||||
@@ -1,3 +0,0 @@
|
||||
-- Add only_in_stock toggle to configuration settings persisted in MariaDB.
|
||||
ALTER TABLE qt_configurations
|
||||
ADD COLUMN IF NOT EXISTS only_in_stock BOOLEAN NOT NULL DEFAULT FALSE AFTER disable_price_refresh;
|
||||
@@ -1,19 +0,0 @@
|
||||
-- Ensure fast lookup for /api/quote/price-levels batched queries:
|
||||
-- SELECT ... FROM qt_pricelist_items WHERE pricelist_id = ? AND lot_name IN (...)
|
||||
SET @has_idx := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'qt_pricelist_items'
|
||||
AND index_name IN ('idx_qt_pricelist_items_pricelist_lot', 'idx_pricelist_lot')
|
||||
);
|
||||
|
||||
SET @ddl := IF(
|
||||
@has_idx = 0,
|
||||
'ALTER TABLE qt_pricelist_items ADD INDEX idx_qt_pricelist_items_pricelist_lot (pricelist_id, lot_name)',
|
||||
'SELECT ''idx_qt_pricelist_items_pricelist_lot already exists, skip'''
|
||||
);
|
||||
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE qt_configurations
|
||||
ADD COLUMN IF NOT EXISTS article VARCHAR(80) NULL AFTER server_count;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE qt_configurations
|
||||
ADD COLUMN IF NOT EXISTS server_model VARCHAR(100) NULL AFTER server_count;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE qt_configurations
|
||||
ADD COLUMN IF NOT EXISTS support_code VARCHAR(20) NULL AFTER server_model;
|
||||
@@ -1,315 +0,0 @@
|
||||
# Промпт для ИИ: Перенос паттерна Прайслист
|
||||
|
||||
Используй этот документ как промпт для ИИ при переносе реализации прайслиста в другой проект.
|
||||
|
||||
---
|
||||
|
||||
## Задача
|
||||
|
||||
Я имею рабочую реализацию окна "Прайслист" в проекте QuoteForge. Нужно перенести эту реализацию в проект [ДОП_ПРОЕКТ_НАЗВАНИЕ], сохраняя структуру, логику и UI/UX.
|
||||
|
||||
## Что перенести
|
||||
|
||||
### Frontend - Лист прайслистов (`/pricelists`)
|
||||
|
||||
**Файл источник:** QuoteForge/web/templates/pricelists.html
|
||||
|
||||
**Компоненты:**
|
||||
1. **Таблица** - список прайслистов с колонками:
|
||||
- Версия (монофонт)
|
||||
- Тип (estimate/warehouse/competitor)
|
||||
- Дата создания
|
||||
- Автор (обычно "sync")
|
||||
- Позиций (количество товаров)
|
||||
- Исп. (использований)
|
||||
- Статус (зеленый "Активен" / серый "Неактивен")
|
||||
- Действия (Просмотр, Удалить если не используется)
|
||||
|
||||
2. **Пагинация** - навигация по страницам с активной страницей выделена
|
||||
|
||||
3. **Модальное окно** - "Создать прайслист" (если есть прав на запись)
|
||||
|
||||
**Что копировать:**
|
||||
- HTML структуру таблицы из lines 10-30
|
||||
- JavaScript функции:
|
||||
- `loadPricelists(page)` - загрузка списка
|
||||
- `renderPricelists(items)` - рендер таблицы
|
||||
- `renderPagination(total, page, perPage)` - пагинация
|
||||
- `checkPricelistWritePermission()` - проверка прав
|
||||
- Модальные функции: `openCreateModal()`, `closeCreateModal()`, `createPricelist()`
|
||||
- CSS классы Tailwind (скопируются как есть)
|
||||
|
||||
**Где использовать в дочернем проекте:**
|
||||
- URL: `/pricelists` (или адаптировать под ваши маршруты)
|
||||
- API: `GET /api/pricelists?page=1&per_page=20`
|
||||
|
||||
---
|
||||
|
||||
### Frontend - Детали прайслиста (`/pricelists/:id`)
|
||||
|
||||
**Файл источник:** QuoteForge/web/templates/pricelist_detail.html
|
||||
|
||||
**Компоненты:**
|
||||
1. **Хлебная крошка** - кнопка назад на список
|
||||
|
||||
2. **Инфо-панель** - сводка по прайслисту:
|
||||
- Версия (монофонт)
|
||||
- Дата создания
|
||||
- Автор
|
||||
- Позиций (количество)
|
||||
- Использований (в скольких конфигах)
|
||||
- Статус (зеленый/серый)
|
||||
- Истекает (дата или "-")
|
||||
|
||||
3. **Таблица товаров** - с поиском и пагинацией:
|
||||
- Артикул (монофонт, lot_name)
|
||||
- Категория (извлекается первая часть до "_")
|
||||
- Описание (обрезается до 60 символов с "...")
|
||||
- [УСЛОВНО] Доступно (qty) - только для warehouse источника
|
||||
- [УСЛОВНО] Partnumbers - только для warehouse источника
|
||||
- Цена, $ (с 2 знаками после запятой)
|
||||
- Настройки (аббревиатуры: РУЧН, Сред, Взвеш.мед, периоды (1н, 1м, 3м, 1г), коэффициент, МЕТА)
|
||||
|
||||
4. **Поиск** - дебаунс 300мс, поиск по lot_name
|
||||
|
||||
5. **Динамические колонки** - qty и partnumbers скрываются/показываются в зависимости от source (warehouse или нет)
|
||||
|
||||
**Что копировать:**
|
||||
- HTML структуру из lines 4-78
|
||||
- JavaScript функции:
|
||||
- `loadPricelistInfo()` - загрузка деталей прайслиста
|
||||
- `loadItems(page)` - загрузка товаров
|
||||
- `renderItems(items)` - рендер таблицы товаров
|
||||
- `renderItemsPagination(total, page, perPage)` - пагинация товаров
|
||||
- `isWarehouseSource()` - проверка источника
|
||||
- `toggleWarehouseColumns()` - показать/скрыть conditional колонки
|
||||
- `formatQty(qty)` - форматирование количества
|
||||
- `formatPriceSettings(item)` - форматирование строки настроек
|
||||
- `escapeHtml(text)` - экранирование HTML
|
||||
- Debounce для поиска (lines 300-306)
|
||||
- CSS классы Tailwind
|
||||
- Логику conditional колонок (lines 152-164)
|
||||
|
||||
**Где использовать в дочернем проекте:**
|
||||
- URL: `/pricelists/:id`
|
||||
- API:
|
||||
- `GET /api/pricelists/:id`
|
||||
- `GET /api/pricelists/:id/items?page=1&per_page=50&search=...`
|
||||
|
||||
---
|
||||
|
||||
### Backend - Handler
|
||||
|
||||
**Файл источник:** QuoteForge/internal/handlers/pricelist.go
|
||||
|
||||
**Методы для реализации:**
|
||||
|
||||
1. **List** (lines 23-89)
|
||||
- Параметры: `page`, `per_page`, `source` (фильтр), `active_only`
|
||||
- Логика:
|
||||
- Получить все прайслисты
|
||||
- Отфильтровать по source (case-insensitive)
|
||||
- Отсортировать по CreatedAt DESC (свежее сверху)
|
||||
- Пагинировать
|
||||
- Для каждого: посчитать товары (CountLocalPricelistItems), использования (IsUsed)
|
||||
- Вернуть JSON с полями: id, source, version, created_by, item_count, usage_count, is_active, created_at, synced_from
|
||||
|
||||
2. **Get** (lines 92-116)
|
||||
- Параметр: `id` (uint из URL)
|
||||
- Логика:
|
||||
- Получить прайслист по ID
|
||||
- Вернуть его детали (id, source, version, item_count, is_active, created_at)
|
||||
- 404 если не найден
|
||||
|
||||
3. **GetItems** (lines 119-181)
|
||||
- Параметры: `id` (URL), `page`, `per_page`, `search` (query)
|
||||
- Логика:
|
||||
- Получить прайслист по ID
|
||||
- Получить товары этого прайслиста
|
||||
- Фильтровать по lot_name LIKE search (если передан)
|
||||
- Посчитать total
|
||||
- Пагинировать
|
||||
- Для каждого товара: извлечь категорию из lot_name (первая часть до "_")
|
||||
- Вернуть JSON: source, items (id, lot_name, price, category, available_qty, partnumbers), total, page, per_page
|
||||
|
||||
4. **GetLotNames** (lines 183-211)
|
||||
- Параметр: `id` (URL)
|
||||
- Логика:
|
||||
- Получить все lot_names из этого прайслиста
|
||||
- Отсортировать alphabetically
|
||||
- Вернуть JSON: lot_names (array of strings), total
|
||||
|
||||
5. **GetLatest** (lines 214-233)
|
||||
- Параметр: `source` (query, default "estimate")
|
||||
- Логика:
|
||||
- Нормализовать source (case-insensitive)
|
||||
- Получить самый свежий прайслист по этому source
|
||||
- Вернуть его детали
|
||||
- 404 если не найден
|
||||
|
||||
**Регистрация маршрутов:**
|
||||
```go
|
||||
pricelists := api.Group("/pricelists")
|
||||
{
|
||||
pricelists.GET("", handler.List)
|
||||
pricelists.GET("/latest", handler.GetLatest)
|
||||
pricelists.GET("/:id", handler.Get)
|
||||
pricelists.GET("/:id/items", handler.GetItems)
|
||||
pricelists.GET("/:id/lots", handler.GetLotNames)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Адаптация для другого проекта
|
||||
|
||||
### Что нужно изменить
|
||||
|
||||
1. **Источник данных**
|
||||
- QuoteForge использует local DB (LocalPricelist, LocalPricelistItem)
|
||||
- В вашем проекте: замените на ваши структуры/таблицы
|
||||
- Сущность "прайслист" может называться по-другому
|
||||
|
||||
2. **API маршруты**
|
||||
- `/api/pricelists` → ваш путь
|
||||
- `:id` - может быть UUID вместо int, адаптировать parsing
|
||||
|
||||
3. **Имена полей**
|
||||
- Если у вас нет поля `version` - используйте ID или дату
|
||||
- Если нет `source` - опустить фильтр
|
||||
- Если нет `IsUsed` - считать как всегда 0
|
||||
|
||||
4. **Структуры данных**
|
||||
- Pricelist должна иметь: id, name/version, created_at, source, item_count
|
||||
- PricelistItem должна иметь: id, lot_name, price, available_qty, partnumbers
|
||||
|
||||
5. **Условные колонки**
|
||||
- Логика: если source == "warehouse", показать qty и partnumbers
|
||||
- Адаптировать под ваши источники/типы
|
||||
|
||||
### Что копировать как есть
|
||||
|
||||
- **HTML структура** - таблицы, модали, классы Tailwind
|
||||
- **JavaScript логика** - все функции загрузки, рендера, пагинации
|
||||
- **CSS классы** - Tailwind работает везде одинаково
|
||||
- **Форматирование функций** - formatPrice, formatQty, formatDate
|
||||
|
||||
---
|
||||
|
||||
## Пошаговая инструкция для ИИ
|
||||
|
||||
1. **Прочитай оба файла:**
|
||||
- QuoteForge/web/templates/pricelists.html (список)
|
||||
- QuoteForge/web/templates/pricelist_detail.html (детали)
|
||||
- QuoteForge/internal/handlers/pricelist.go (backend)
|
||||
|
||||
2. **Определи структуры данных в дочернем проекте:**
|
||||
- Какая таблица хранит "прайслисты"?
|
||||
- Какие у неё поля?
|
||||
- Как связаны товары?
|
||||
|
||||
3. **Адаптируй Backend:**
|
||||
- Скопируй методы Handler
|
||||
- Замени DB вызовы на вызовы вашего хранилища
|
||||
- Замени имена полей в JSON ответах если нужно
|
||||
- Убедись, что API возвращает нужный формат
|
||||
|
||||
4. **Адаптируй Frontend - Список:**
|
||||
- Скопируй HTML таблицу
|
||||
- Скопируй функции load/render/pagination
|
||||
- Замени маршруты `/pricelists` → ваши
|
||||
- Замени API endpoint → ваш
|
||||
- Протестируй список загружается
|
||||
|
||||
5. **Адаптируй Frontend - Детали:**
|
||||
- Скопируй HTML для деталей
|
||||
- Скопируй функции loadInfo/loadItems/render
|
||||
- Замени маршруты и endpoints
|
||||
- Особое внимание на conditional колонки (toggleWarehouseColumns)
|
||||
- Протестируй поиск работает
|
||||
|
||||
6. **Протестируй:**
|
||||
- Список загружается
|
||||
- Пагинация работает
|
||||
- Детали открываются
|
||||
- Поиск работает
|
||||
- Conditional колонки показываются/скрываются правильно
|
||||
- Форматирование цен и дат работает
|
||||
|
||||
---
|
||||
|
||||
## Пример адаптации
|
||||
|
||||
### Backend (было):
|
||||
```go
|
||||
func (h *PricelistHandler) List(c *gin.Context) {
|
||||
localPLs, err := h.localDB.GetLocalPricelists()
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Backend (стало):
|
||||
```go
|
||||
func (h *CatalogHandler) List(c *gin.Context) {
|
||||
catalogs, err := h.service.GetAllCatalogs(page, perPage)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Frontend (было):
|
||||
```javascript
|
||||
const resp = await fetch(`/api/pricelists?page=${page}&per_page=20`);
|
||||
```
|
||||
|
||||
### Frontend (стало):
|
||||
```javascript
|
||||
const resp = await fetch(`/api/catalogs?page=${page}&per_page=20`);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Качество результата
|
||||
|
||||
Когда закончишь:
|
||||
- ✅ Список и детали выглядят идентично QuoteForge
|
||||
- ✅ Все функции работают (load, render, pagination, search, conditional columns)
|
||||
- ✅ Обработка ошибок (404, empty list, network errors)
|
||||
- ✅ Таблицы с Tailwind классами оформлены одинаково
|
||||
- ✅ Форматирование чисел/дат совпадает
|
||||
|
||||
---
|
||||
|
||||
## Вопросы для ИИ
|
||||
|
||||
Перед тем как давать этот промпт, ответь на эти вопросы:
|
||||
|
||||
1. **Какие у тебя структуры данных для "прайслиста"?**
|
||||
- Пример: какие поля, как называется таблица
|
||||
|
||||
2. **Какие API endpoints уже есть?**
|
||||
- Или нужно создать с нуля?
|
||||
|
||||
3. **Есть ли уже разница в источниках (estimate/warehouse)?**
|
||||
- Или все одного типа?
|
||||
|
||||
4. **Нужна ли возможность создавать прайслисты?**
|
||||
- Или только просмотр?
|
||||
|
||||
---
|
||||
|
||||
## Чеклист для проверки
|
||||
|
||||
После переноса проверь:
|
||||
|
||||
- [ ] Backend: List возвращает правильный JSON
|
||||
- [ ] Backend: Get возвращает детали
|
||||
- [ ] Backend: GetItems возвращает товары с поиском
|
||||
- [ ] Frontend: Список загружается на `/pricelists`
|
||||
- [ ] Frontend: Клик на прайслист открывает `/pricelists/:id`
|
||||
- [ ] Frontend: Таблица на детальной странице рендеритсяся
|
||||
- [ ] Frontend: Поиск работает с дебаунсом
|
||||
- [ ] Frontend: Пагинация работает
|
||||
- [ ] Frontend: Conditional колонки показываются/скрываются
|
||||
- [ ] Frontend: Форматирование цен работает (2 знака)
|
||||
- [ ] Frontend: Форматирование дат работает (ru-RU)
|
||||
- [ ] UI: Выглядит идентично QuoteForge
|
||||
@@ -1,72 +0,0 @@
|
||||
# v1.2.1 Release Notes
|
||||
|
||||
**Date:** 2026-02-09
|
||||
**Changes since v1.2.0:** 2 commits
|
||||
|
||||
## Summary
|
||||
Fixed configurator component substitution by updating to work with new pricelist-based pricing model. Addresses regression from v1.2.0 refactor that removed `CurrentPrice` field from components.
|
||||
|
||||
## Commits
|
||||
|
||||
### 1. Refactor: Remove CurrentPrice from local_components (5984a57)
|
||||
**Type:** Refactor
|
||||
**Files Changed:** 11 files, +167 insertions, -194 deletions
|
||||
|
||||
#### Overview
|
||||
Transitioned from component-based pricing to pricelist-based pricing model:
|
||||
- Removed `CurrentPrice` and `SyncedAt` from LocalComponent (metadata-only now)
|
||||
- Added `WarehousePricelistID` and `CompetitorPricelistID` to LocalConfiguration
|
||||
- Removed 2 unused methods: UpdateComponentPricesFromPricelist, EnsureComponentPricesFromPricelists
|
||||
|
||||
#### Key Changes
|
||||
- **Data Model:**
|
||||
- LocalComponent: now stores only metadata (LotName, LotDescription, Category, Model)
|
||||
- LocalConfiguration: added warehouse and competitor pricelist references
|
||||
|
||||
- **Migrations:**
|
||||
- drop_component_unused_fields - removes CurrentPrice, SyncedAt columns
|
||||
- add_warehouse_competitor_pricelists - adds new pricelist fields
|
||||
|
||||
- **Quote Calculation:**
|
||||
- Updated to use pricelist_items instead of component.CurrentPrice
|
||||
- Added PricelistID field to QuoteRequest
|
||||
- Maintains offline-first behavior
|
||||
|
||||
- **API:**
|
||||
- Removed CurrentPrice from ComponentView
|
||||
- Components API no longer returns pricing
|
||||
|
||||
### 2. Fix: Load component prices via API (acf7c8a)
|
||||
**Type:** Bug Fix
|
||||
**Files Changed:** 1 file (web/templates/index.html), +66 insertions, -12 deletions
|
||||
|
||||
#### Problem
|
||||
After v1.2.0 refactor, the configurator's autocomplete was filtering out all components because it checked for the removed `current_price` field on component objects.
|
||||
|
||||
#### Solution
|
||||
Implemented on-demand price loading via API:
|
||||
- Added `ensurePricesLoaded()` function to fetch prices from `/api/quote/price-levels`
|
||||
- Added `componentPricesCache` to cache loaded prices in memory
|
||||
- Updated all 3 autocomplete modes (single, multi, section) to load prices when input is focused
|
||||
- Changed price validation from `c.current_price` to `hasComponentPrice(lot_name)`
|
||||
- Updated cart item creation to use cached API prices
|
||||
|
||||
#### Impact
|
||||
- Components without prices are still filtered out (as required)
|
||||
- Price checks now use API data instead of removed database field
|
||||
- Frontend loads prices on-demand for better performance
|
||||
|
||||
## Testing Notes
|
||||
- ✅ Configurator component substitution now works
|
||||
- ✅ Prices load correctly from pricelist
|
||||
- ✅ Offline mode still supported (prices cached after initial load)
|
||||
- ✅ Multi-pricelist support functional (estimate/warehouse/competitor)
|
||||
|
||||
## Known Issues
|
||||
None
|
||||
|
||||
## Migration Path
|
||||
No database migration needed from v1.2.0 - migrations were applied in v1.2.0 release.
|
||||
|
||||
## Breaking Changes
|
||||
None for end users. Internal: `ComponentView` no longer includes `CurrentPrice` in API responses.
|
||||
@@ -1,59 +0,0 @@
|
||||
# Release v1.2.2 (2026-02-09)
|
||||
|
||||
## Summary
|
||||
|
||||
Fixed CSV export filename inconsistency where project names weren't being resolved correctly. Standardized export format across both manual exports and project configuration exports to use `YYYY-MM-DD (project_name) config_name BOM.csv`.
|
||||
|
||||
## Commits
|
||||
|
||||
- `8f596ce` fix: standardize CSV export filename format to use project name
|
||||
|
||||
## Changes
|
||||
|
||||
### CSV Export Filename Standardization
|
||||
|
||||
**Problem:**
|
||||
- ExportCSV and ExportConfigCSV had inconsistent filename formats
|
||||
- Project names sometimes fell back to config names when not explicitly provided
|
||||
- Export timestamps didn't reflect actual price update time
|
||||
|
||||
**Solution:**
|
||||
- Unified format: `YYYY-MM-DD (project_name) config_name BOM.csv`
|
||||
- Both export paths now use PriceUpdatedAt if available, otherwise CreatedAt
|
||||
- Project name resolved from ProjectUUID via ProjectService for both paths
|
||||
- Frontend passes project_uuid context when exporting
|
||||
|
||||
**Technical Details:**
|
||||
|
||||
Backend:
|
||||
- Added `ProjectUUID` field to `ExportRequest` struct in handlers/export.go
|
||||
- Updated ExportCSV to look up project name from ProjectUUID using ProjectService
|
||||
- Ensured ExportConfigCSV gets project name from config's ProjectUUID
|
||||
- Both use CreatedAt (for ExportCSV) or PriceUpdatedAt/CreatedAt (for ExportConfigCSV)
|
||||
|
||||
Frontend:
|
||||
- Added `projectUUID` and `projectName` state variables in index.html
|
||||
- Load and store projectUUID when configuration is loaded
|
||||
- Pass `project_uuid` in JSON body for both export requests
|
||||
|
||||
## Files Modified
|
||||
|
||||
- `internal/handlers/export.go` - Project name resolution and ExportRequest update
|
||||
- `internal/handlers/export_test.go` - Updated mock initialization with projectService param
|
||||
- `cmd/qfs/main.go` - Pass projectService to ExportHandler constructor
|
||||
- `web/templates/index.html` - Add projectUUID tracking and export payload updates
|
||||
|
||||
## Testing Notes
|
||||
|
||||
✅ All existing tests updated and passing
|
||||
✅ Code builds without errors
|
||||
✅ Export filename now includes correct project name
|
||||
✅ Works for both form-based and project-based exports
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
None - API response format unchanged, only filename generation updated.
|
||||
|
||||
## Known Issues
|
||||
|
||||
None identified.
|
||||
@@ -1,95 +0,0 @@
|
||||
# Release v1.2.3 (2026-02-10)
|
||||
|
||||
## Summary
|
||||
|
||||
Unified synchronization functionality with event-driven UI updates. Resolved user confusion about duplicate sync buttons by implementing a single sync source with automatic page refreshes.
|
||||
|
||||
## Changes
|
||||
|
||||
### Main Feature: Sync Event System
|
||||
|
||||
- **Added `sync-completed` event** in base.html's `syncAction()` function
|
||||
- Dispatched after successful `/api/sync/all` or `/api/sync/push`
|
||||
- Includes endpoint and response data in event detail
|
||||
- Enables pages to react automatically to sync completion
|
||||
|
||||
### Configs Page (`configs.html`)
|
||||
|
||||
- **Removed "Импорт с сервера" button** - duplicate functionality no longer needed
|
||||
- **Updated layout** - changed from 2-column grid to single button layout
|
||||
- **Removed `importConfigsFromServer()` function** - functionality now handled by navbar sync
|
||||
- **Added sync-completed event listener**:
|
||||
- Automatically reloads configurations list after sync
|
||||
- Resets pagination to first page
|
||||
- New configurations appear immediately without manual refresh
|
||||
|
||||
### Projects Page (`projects.html`)
|
||||
|
||||
- **Wrapped initialization in DOMContentLoaded**:
|
||||
- Moved `loadProjects()` and all event listeners inside handler
|
||||
- Ensures DOM is fully loaded before accessing elements
|
||||
- **Added sync-completed event listener**:
|
||||
- Automatically reloads projects list after sync
|
||||
- New projects appear immediately without manual refresh
|
||||
|
||||
### Pricelists Page (`pricelists.html`)
|
||||
|
||||
- **Added sync-completed event listener** to existing DOMContentLoaded:
|
||||
- Automatically reloads pricelists when sync completes
|
||||
- Maintains existing permissions and modal functionality
|
||||
|
||||
## Benefits
|
||||
|
||||
### User Experience
|
||||
- ✅ Single "Синхронизация" button in navbar - no confusion about sync sources
|
||||
- ✅ Automatic list updates after sync - no need for manual F5 refresh
|
||||
- ✅ Consistent behavior across all pages (configs, projects, pricelists)
|
||||
- ✅ Better feedback: toast notification + automatic UI refresh
|
||||
|
||||
### Architecture
|
||||
- ✅ Event-driven loose coupling between navbar and pages
|
||||
- ✅ Easy to extend to other pages (just add event listener)
|
||||
- ✅ No backend changes needed
|
||||
- ✅ Production-ready
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
- **`/api/configs/import` endpoint** still works but UI button removed
|
||||
- Users should use navbar "Синхронизация" button instead
|
||||
- Backend API remains unchanged for backward compatibility
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. `web/templates/base.html` - Added sync-completed event dispatch
|
||||
2. `web/templates/configs.html` - Event listener + removed duplicate UI
|
||||
3. `web/templates/projects.html` - DOMContentLoaded wrapper + event listener
|
||||
4. `web/templates/pricelists.html` - Event listener for auto-refresh
|
||||
|
||||
**Stats:** 4 files changed, 59 insertions(+), 65 deletions(-)
|
||||
|
||||
## Commits
|
||||
|
||||
- `99fd80b` - feat: unify sync functionality with event-driven UI updates
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [x] Configs page: New configurations appear after navbar sync
|
||||
- [x] Projects page: New projects appear after navbar sync
|
||||
- [x] Pricelists page: Pricelists refresh after navbar sync
|
||||
- [x] Both `/api/sync/all` and `/api/sync/push` trigger updates
|
||||
- [x] Toast notifications still show correctly
|
||||
- [x] Sync status indicator updates
|
||||
- [x] Error handling (423, network errors) still works
|
||||
- [x] Mode switching (Active/Archive) works correctly
|
||||
- [x] Backward compatibility maintained
|
||||
|
||||
## Known Issues
|
||||
|
||||
None - implementation is production-ready
|
||||
|
||||
## Migration Notes
|
||||
|
||||
No migration needed. Changes are frontend-only and backward compatible:
|
||||
- Old `/api/configs/import` endpoint still functional
|
||||
- No database schema changes
|
||||
- No configuration changes needed
|
||||
@@ -1,89 +0,0 @@
|
||||
# QuoteForge v1.2.1
|
||||
|
||||
**Дата релиза:** 2026-02-09
|
||||
**Тег:** `v1.2.1`
|
||||
**GitHub:** https://git.mchus.pro/mchus/QuoteForge/releases/tag/v1.2.1
|
||||
|
||||
## Резюме
|
||||
|
||||
Быстрый патч-релиз, исправляющий регрессию в конфигураторе после рефактора v1.2.0. После удаления поля `CurrentPrice` из компонентов, autocomplete перестал показывать компоненты. Теперь используется на-demand загрузка цен через API.
|
||||
|
||||
## Что исправлено
|
||||
|
||||
### 🐛 Configurator Component Substitution (acf7c8a)
|
||||
- **Проблема:** После рефактора в v1.2.0, autocomplete фильтровал ВСЕ компоненты, потому что проверял удаленное поле `current_price`
|
||||
- **Решение:** Загрузка цен на-demand через `/api/quote/price-levels`
|
||||
- Добавлен `componentPricesCache` для кэширования цен в памяти
|
||||
- Функция `ensurePricesLoaded()` загружает цены при фокусе на поле поиска
|
||||
- Все 3 режима autocomplete (single, multi, section) обновлены
|
||||
- Компоненты без цен по-прежнему фильтруются (как требуется), но проверка использует API
|
||||
- **Затронутые файлы:** `web/templates/index.html` (+66 строк, -12 строк)
|
||||
|
||||
## История v1.2.0 → v1.2.1
|
||||
|
||||
Всего коммитов: **2**
|
||||
|
||||
| Хеш | Автор | Сообщение |
|
||||
|-----|-------|-----------|
|
||||
| `acf7c8a` | Claude | fix: load component prices via API instead of removed current_price field |
|
||||
| `5984a57` | Claude | refactor: remove CurrentPrice from local_components and transition to pricelist-based pricing |
|
||||
|
||||
## Тестирование
|
||||
|
||||
✅ Configurator component substitution работает
|
||||
✅ Цены загружаются корректно из pricelist
|
||||
✅ Offline режим поддерживается (цены кэшируются после первой загрузки)
|
||||
✅ Multi-pricelist поддержка функциональна (estimate/warehouse/competitor)
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
Нет критических изменений для конечных пользователей.
|
||||
|
||||
⚠️ **Для разработчиков:** `ComponentView` API больше не возвращает `CurrentPrice`.
|
||||
|
||||
## Миграция
|
||||
|
||||
Не требуется миграция БД — все миграции были применены в v1.2.0.
|
||||
|
||||
## Установка
|
||||
|
||||
### macOS
|
||||
|
||||
```bash
|
||||
# Скачать и распаковать
|
||||
tar xzf qfs-v1.2.1-darwin-arm64.tar.gz # для Apple Silicon
|
||||
# или
|
||||
tar xzf qfs-v1.2.1-darwin-amd64.tar.gz # для Intel Mac
|
||||
|
||||
# Снять ограничение Gatekeeper (если требуется)
|
||||
xattr -d com.apple.quarantine ./qfs
|
||||
|
||||
# Запустить
|
||||
./qfs
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
```bash
|
||||
tar xzf qfs-v1.2.1-linux-amd64.tar.gz
|
||||
./qfs
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
```bash
|
||||
# Распаковать qfs-v1.2.1-windows-amd64.zip
|
||||
# Запустить qfs.exe
|
||||
```
|
||||
|
||||
## Известные проблемы
|
||||
|
||||
Нет известных проблем на момент релиза.
|
||||
|
||||
## Поддержка
|
||||
|
||||
По вопросам обращайтесь: [@mchus](https://git.mchus.pro/mchus)
|
||||
|
||||
---
|
||||
|
||||
*Отправлено с ❤️ через Claude Code*
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if ! git rev-parse --git-dir >/dev/null 2>&1; then
|
||||
echo "Not inside a git repository."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v rg >/dev/null 2>&1; then
|
||||
echo "ripgrep (rg) is required for secret scanning."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
staged_files=()
|
||||
while IFS= read -r file; do
|
||||
staged_files+=("$file")
|
||||
done < <(git diff --cached --name-only --diff-filter=ACMRTUXB)
|
||||
|
||||
if [ "${#staged_files[@]}" -eq 0 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
secret_pattern='AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{35}|-----BEGIN (RSA|OPENSSH|EC|DSA|PRIVATE) KEY-----|(?i)(password|passwd|pwd|secret|token|api[_-]?key|jwt_secret)\s*[:=]\s*["'"'"'][^"'"'"'\s]{8,}["'"'"']'
|
||||
allow_pattern='CHANGE_ME|REDACTED|PLACEHOLDER|EXAMPLE|example|<[^>]+>'
|
||||
|
||||
found=0
|
||||
|
||||
for file in "${staged_files[@]}"; do
|
||||
case "$file" in
|
||||
dist/*|*.png|*.jpg|*.jpeg|*.gif|*.webp|*.pdf|*.zip|*.gz|*.exe|*.dll|*.so|*.dylib)
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
|
||||
if ! content="$(git show ":$file" 2>/dev/null)"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
hits="$(printf '%s' "$content" | rg -n --no-heading -e "$secret_pattern" || true)"
|
||||
if [ -n "$hits" ]; then
|
||||
filtered="$(printf '%s\n' "$hits" | rg -v -e "$allow_pattern" || true)"
|
||||
if [ -n "$filtered" ]; then
|
||||
echo "Potential secret found in staged file: $file"
|
||||
printf '%s\n' "$filtered"
|
||||
found=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$found" -ne 0 ]; then
|
||||
echo
|
||||
echo "Commit blocked: remove or redact secrets before committing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -25,25 +25,6 @@ echo ""
|
||||
RELEASE_DIR="releases/${VERSION}"
|
||||
mkdir -p "${RELEASE_DIR}"
|
||||
|
||||
# Create release notes template (always include macOS Gatekeeper note)
|
||||
if [ ! -f "${RELEASE_DIR}/RELEASE_NOTES.md" ]; then
|
||||
cat > "${RELEASE_DIR}/RELEASE_NOTES.md" <<EOF
|
||||
# QuoteForge ${VERSION}
|
||||
|
||||
Дата релиза: $(date +%Y-%m-%d)
|
||||
Тег: \`${VERSION}\`
|
||||
|
||||
## Что нового
|
||||
|
||||
- TODO: опишите ключевые изменения релиза.
|
||||
|
||||
## Запуск на macOS
|
||||
|
||||
Снимите карантинный атрибут через терминал: \`xattr -d com.apple.quarantine /path/to/qfs-darwin-arm64\`
|
||||
После этого бинарник запустится без предупреждения Gatekeeper.
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Build for all platforms
|
||||
echo -e "${YELLOW}→ Building binaries...${NC}"
|
||||
make build-all
|
||||
|
||||
78
todo.md
78
todo.md
@@ -1,78 +0,0 @@
|
||||
# QuoteForge — План очистки (удаление admin pricing)
|
||||
|
||||
Цель: убрать всё, что связано с администрированием цен, складскими справками, алертами.
|
||||
Оставить: конфигуратор, проекты, read-only просмотр прайслистов, sync, offline-first.
|
||||
|
||||
---
|
||||
|
||||
## 1. Удалить файлы
|
||||
|
||||
- [x] `internal/handlers/pricing.go` (40.6KB) — весь admin pricing UI
|
||||
- [x] `internal/services/pricing/` — весь пакет расчёта цен
|
||||
- [x] `internal/services/pricelist/` — весь пакет управления прайслистами
|
||||
- [x] `internal/services/stock_import.go` — импорт складских справок
|
||||
- [x] `internal/services/alerts/` — весь пакет алертов
|
||||
- [x] `internal/warehouse/` — алгоритмы расчёта цен по складу
|
||||
- [x] `web/templates/admin_pricing.html` (109KB) — страница admin pricing
|
||||
- [x] `cmd/cron/` — cron jobs (cleanup-pricelists, update-prices, update-popularity)
|
||||
- [x] `cmd/importer/` — утилита импорта данных
|
||||
|
||||
## 2. Упростить `internal/handlers/pricelist.go` (read-only)
|
||||
|
||||
Read-only методы (List, Get, GetItems, GetLotNames, GetLatest) уже работают
|
||||
только через `h.localDB` (SQLite) без `pricelist.Service`.
|
||||
|
||||
- [x] Убрать поле `service *pricelist.Service` из структуры `PricelistHandler`
|
||||
- [x] Изменить конструктор: `NewPricelistHandler(localDB *localdb.LocalDB)`
|
||||
- [x] Удалить write-методы: `Create()`, `CreateWithProgress()`, `Delete()`, `SetActive()`, `CanWrite()`
|
||||
- [x] Удалить метод `refreshLocalPricelistCacheFromServer()` (зависит от service)
|
||||
- [x] Удалить import `pricelist` пакета
|
||||
- [x] Оставить: `List()`, `Get()`, `GetItems()`, `GetLotNames()`, `GetLatest()`
|
||||
|
||||
## 3. Упростить `cmd/qfs/main.go`
|
||||
|
||||
- [x] Удалить создание сервисов: `pricingService`, `alertService`, `pricelistService`, `stockImportService`
|
||||
- [x] Удалить хэндлер: `pricingHandler`
|
||||
- [x] Изменить создание `pricelistHandler`: `NewPricelistHandler(local)` (без service)
|
||||
- [x] Удалить repositories: `priceRepo`, `alertRepo` (statsRepo оставить — nil-safe)
|
||||
- [x] Удалить все routes `/api/admin/pricing/*` (строки ~1407-1430)
|
||||
- [x] Из `/api/pricelists/*` оставить только read-only:
|
||||
- `GET ""` (List), `GET "/latest"`, `GET "/:id"`, `GET "/:id/items"`, `GET "/:id/lots"`
|
||||
- [x] Удалить write routes: `POST ""`, `POST "/create-with-progress"`, `PATCH "/:id/active"`, `DELETE "/:id"`, `GET "/can-write"`
|
||||
- [x] Удалить web page `/admin/pricing`
|
||||
- [x] Исправить `/pricelists` — вместо redirect на admin/pricing сделать страницу
|
||||
- [x] В `QuoteService` конструкторе: передавать `nil` для `pricingService`
|
||||
- [x] Удалить imports: `pricing`, `pricelist`, `alerts` пакеты
|
||||
|
||||
## 4. Упростить `handlers/web.go`
|
||||
|
||||
- [x] Удалить из `simplePages`: `admin_pricing.html`
|
||||
- [x] Удалить метод: `AdminPricing()`
|
||||
- [x] Оставить все остальные методы включая `Pricelists()` и `PricelistDetail()`
|
||||
|
||||
## 5. Упростить `base.html` (навигация)
|
||||
|
||||
- [x] Убрать ссылку "Администратор цен"
|
||||
- [x] Добавить ссылку "Прайслисты" (на `/pricelists`)
|
||||
- [x] Оставить: "Мои проекты", "Прайслисты", sync indicator
|
||||
|
||||
## 6. Sync — оставить полностью
|
||||
|
||||
- Background worker: pull компоненты + прайслисты, push конфигурации
|
||||
- Все `/api/sync/*` endpoints остаются
|
||||
- Это ядро offline-first архитектуры
|
||||
|
||||
## 7. Верификация
|
||||
|
||||
- [x] `go build ./cmd/qfs` — компилируется
|
||||
- [x] `go vet ./...` — без ошибок
|
||||
- [ ] Запуск → `/configs` работает
|
||||
- [ ] `/pricelists` — read-only список работает
|
||||
- [ ] `/pricelists/:id` — детали работают
|
||||
- [ ] Sync с сервером работает
|
||||
- [ ] Нет ссылок на admin pricing в UI
|
||||
|
||||
## 8. Обновить CLAUDE.md
|
||||
- [x] Убрать разделы про admin pricing, stock import, alerts, cron
|
||||
- [x] Обновить API endpoints список
|
||||
- [x] Обновить описание приложения
|
||||
1121
web/templates/admin_pricing.html
Normal file
1121
web/templates/admin_pricing.html
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user