Compare commits
46 Commits
v1.0.4
...
d1f65f6684
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1f65f6684 | ||
|
|
7b371add10 | ||
|
|
8d7fab39b4 | ||
|
|
1906a74759 | ||
| e5b6902c9e | |||
|
|
3c46cd7bf0 | ||
|
|
7f8491d197 | ||
|
|
3fd7a2231a | ||
|
|
c295b60dd8 | ||
| cc9b846c31 | |||
| 87cb12906d | |||
| 075fc709dd | |||
| cbaeafa9c8 | |||
| 71f73e2f1d | |||
| 2e973b6d78 | |||
| 8508ee2921 | |||
| b153afbf51 | |||
|
|
9b5d57902d | ||
|
|
4e1a46bd71 | ||
|
|
857ec7a0e5 | ||
|
|
01f21fa5ac | ||
|
|
a1edca3be9 | ||
|
|
7fbf813952 | ||
|
|
e58fd35ee4 | ||
|
|
e3559035f7 | ||
|
|
5edffe822b | ||
|
|
99fd80bca7 | ||
|
|
d8edd5d5f0 | ||
|
|
9cb17ee03f | ||
|
|
8f596cec68 | ||
|
|
8fd27d11a7 | ||
|
|
600f842b82 | ||
|
|
acf7c8a4da | ||
|
|
5984a57a8b | ||
|
|
84dda8cf0a | ||
|
|
abeb26d82d | ||
|
|
29edd73744 | ||
|
|
e8d0e28415 | ||
|
|
08feda9af6 | ||
|
|
af79b6f3bf | ||
|
|
bca82f9dc0 | ||
| 17969277e6 | |||
| 0dbfe45353 | |||
| f609d2ce35 | |||
| 593280de99 | |||
| eb8555c11a |
5
.githooks/pre-commit
Executable file
5
.githooks/pre-commit
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(git rev-parse --show-toplevel)"
|
||||
"$repo_root/scripts/check-secrets.sh"
|
||||
17
.gitignore
vendored
17
.gitignore
vendored
@@ -1,5 +1,16 @@
|
||||
# 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
|
||||
@@ -12,6 +23,7 @@ config.yaml
|
||||
/importer
|
||||
/cron
|
||||
/bin/
|
||||
qfs
|
||||
|
||||
# Local Go build cache used in sandboxed runs
|
||||
.gocache/
|
||||
@@ -63,4 +75,7 @@ Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
releases/
|
||||
# Release artifacts (binaries, archives, checksums), but DO track releases/memory/ for changelog
|
||||
releases/*
|
||||
!releases/memory/
|
||||
!releases/memory/**
|
||||
|
||||
69
CLAUDE.md
69
CLAUDE.md
@@ -1,71 +1,24 @@
|
||||
# QuoteForge - Claude Code Instructions
|
||||
|
||||
## Overview
|
||||
Корпоративный конфигуратор серверов с offline-first архитектурой.
|
||||
Приложение работает через локальную SQLite базу, синхронизация с MariaDB выполняется фоново.
|
||||
## Bible
|
||||
|
||||
## Product Scope
|
||||
- Конфигуратор компонентов и расчёт КП
|
||||
- Проекты и конфигурации
|
||||
- Read-only просмотр прайслистов из локального кэша
|
||||
- Sync (pull компонентов/прайслистов, push локальных изменений)
|
||||
The **[bible/](bible/README.md)** is the single source of truth for this project's architecture, schemas, patterns, and rules. Read it before making any changes.
|
||||
|
||||
Из области исключены:
|
||||
- admin pricing UI/API
|
||||
- stock import
|
||||
- alerts
|
||||
- cron/importer утилиты
|
||||
**Rules:**
|
||||
- Every architectural decision must be recorded in `bible/` in the same commit as the code.
|
||||
- Bible files are written and updated in **English only**.
|
||||
- Before working on the codebase, check `releases/memory/` for the latest release notes.
|
||||
|
||||
## Architecture
|
||||
- Local-first: чтение и запись происходят в SQLite
|
||||
- MariaDB используется как сервер синхронизации
|
||||
- Background worker: периодический sync push+pull
|
||||
## Quick Reference
|
||||
|
||||
## Key SQLite Data
|
||||
- `connection_settings`
|
||||
- `local_components`
|
||||
- `local_pricelists`, `local_pricelist_items`
|
||||
- `local_configurations`
|
||||
- `local_projects`
|
||||
- `pending_changes`
|
||||
|
||||
## 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`
|
||||
|
||||
## Commands
|
||||
```bash
|
||||
# Development
|
||||
# Verify build
|
||||
go build ./cmd/qfs && go vet ./...
|
||||
|
||||
# Run
|
||||
go run ./cmd/qfs
|
||||
make run
|
||||
|
||||
# Build
|
||||
make build-release
|
||||
CGO_ENABLED=0 go build -o bin/qfs ./cmd/qfs
|
||||
|
||||
# Verification
|
||||
go build ./cmd/qfs
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
## Code Style
|
||||
- gofmt
|
||||
- structured logging (`slog`)
|
||||
- explicit error wrapping with context
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
# Local-First Architecture Integration Guide
|
||||
|
||||
## Overview
|
||||
|
||||
QuoteForge теперь поддерживает local-first архитектуру: приложение ВСЕГДА работает с SQLite (localdb), MariaDB используется только для синхронизации.
|
||||
|
||||
## Реализованные компоненты
|
||||
|
||||
### 1. Конвертеры моделей (`internal/localdb/converters.go`)
|
||||
|
||||
Конвертеры между MariaDB и SQLite моделями:
|
||||
- `ConfigurationToLocal()` / `LocalToConfiguration()`
|
||||
- `PricelistToLocal()` / `LocalToPricelist()`
|
||||
- `ComponentToLocal()` / `LocalToComponent()`
|
||||
|
||||
### 2. LocalDB методы (`internal/localdb/localdb.go`)
|
||||
|
||||
Добавлены методы для работы с pending changes:
|
||||
- `MarkChangesSynced(ids []int64)` - помечает изменения как синхронизированные
|
||||
- `GetPendingCount()` - возвращает количество несинхронизированных изменений
|
||||
|
||||
### 3. Sync Service расширения (`internal/services/sync/service.go`)
|
||||
|
||||
Новые методы:
|
||||
- `SyncPricelistsIfNeeded()` - проверяет и скачивает новые прайслисты при необходимости
|
||||
- `PushPendingChanges()` - отправляет все pending changes на сервер
|
||||
- `pushSingleChange()` - обрабатывает один pending change
|
||||
- `pushConfigurationCreate/Update/Delete()` - специфичные методы для конфигураций
|
||||
|
||||
**ВАЖНО**: Конструктор изменен - теперь требует `ConfigurationRepository`:
|
||||
```go
|
||||
syncService := sync.NewService(pricelistRepo, configRepo, local)
|
||||
```
|
||||
|
||||
### 4. LocalConfigurationService (`internal/services/local_configuration.go`)
|
||||
|
||||
Новый сервис для работы с конфигурациями в local-first режиме:
|
||||
- Все операции CRUD работают через SQLite
|
||||
- Автоматически добавляет изменения в pending_changes
|
||||
- При создании конфигурации (если online) проверяет новые прайслисты
|
||||
|
||||
```go
|
||||
localConfigService := services.NewLocalConfigurationService(
|
||||
localDB,
|
||||
syncService,
|
||||
quoteService,
|
||||
isOnlineFunc,
|
||||
)
|
||||
```
|
||||
|
||||
### 5. Sync Handler расширения (`internal/handlers/sync.go`)
|
||||
|
||||
Новые endpoints:
|
||||
- `POST /api/sync/push` - отправить pending changes на сервер
|
||||
- `GET /api/sync/pending/count` - получить количество pending changes
|
||||
- `GET /api/sync/pending` - получить список pending changes
|
||||
|
||||
## Интеграция
|
||||
|
||||
### Шаг 1: Обновить main.go
|
||||
|
||||
```go
|
||||
// В cmd/qfs/main.go
|
||||
syncService := sync.NewService(pricelistRepo, configRepo, local)
|
||||
|
||||
// Создать isOnline функцию
|
||||
isOnlineFunc := func() bool {
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return sqlDB.Ping() == nil
|
||||
}
|
||||
|
||||
// Создать LocalConfigurationService
|
||||
localConfigService := services.NewLocalConfigurationService(
|
||||
local,
|
||||
syncService,
|
||||
quoteService,
|
||||
isOnlineFunc,
|
||||
)
|
||||
```
|
||||
|
||||
### Шаг 2: Обновить ConfigurationHandler
|
||||
|
||||
Заменить `ConfigurationService` на `LocalConfigurationService` в handlers:
|
||||
|
||||
```go
|
||||
// Было:
|
||||
configHandler := handlers.NewConfigurationHandler(configService, exportService)
|
||||
|
||||
// Стало:
|
||||
configHandler := handlers.NewConfigurationHandler(localConfigService, exportService)
|
||||
```
|
||||
|
||||
### Шаг 3: Добавить endpoints для sync
|
||||
|
||||
В роутере добавить:
|
||||
```go
|
||||
syncGroup := router.Group("/api/sync")
|
||||
{
|
||||
syncGroup.POST("/push", syncHandler.PushPendingChanges)
|
||||
syncGroup.GET("/pending/count", syncHandler.GetPendingCount)
|
||||
syncGroup.GET("/pending", syncHandler.GetPendingChanges)
|
||||
}
|
||||
```
|
||||
|
||||
## Как это работает
|
||||
|
||||
### Создание конфигурации
|
||||
|
||||
1. Пользователь создает конфигурацию
|
||||
2. `LocalConfigurationService.Create()`:
|
||||
- Если online → `SyncPricelistsIfNeeded()` проверяет новые прайслисты
|
||||
- Сохраняет конфигурацию в SQLite
|
||||
- Добавляет в `pending_changes` с operation="create"
|
||||
3. Конфигурация доступна локально сразу
|
||||
|
||||
### Синхронизация с сервером
|
||||
|
||||
**Manual sync:**
|
||||
```bash
|
||||
POST /api/sync/push
|
||||
```
|
||||
|
||||
**Background sync (TODO):**
|
||||
- Периодический worker вызывает `syncService.PushPendingChanges()`
|
||||
- Проверяет online статус
|
||||
- Отправляет все pending changes на сервер
|
||||
- Удаляет успешно синхронизированные записи
|
||||
|
||||
### Offline режим
|
||||
|
||||
1. Все операции работают нормально через SQLite
|
||||
2. Изменения копятся в `pending_changes`
|
||||
3. При восстановлении соединения автоматически синхронизируются
|
||||
|
||||
## Pending Changes Queue
|
||||
|
||||
Таблица `pending_changes`:
|
||||
```go
|
||||
type PendingChange struct {
|
||||
ID int64 // Auto-increment
|
||||
EntityType string // "configuration", "project", "specification"
|
||||
EntityUUID string // UUID сущности
|
||||
Operation string // "create", "update", "delete"
|
||||
Payload string // JSON snapshot сущности
|
||||
CreatedAt time.Time
|
||||
Attempts int // Счетчик попыток синхронизации
|
||||
LastError string // Последняя ошибка синхронизации
|
||||
}
|
||||
```
|
||||
|
||||
## TODO для Phase 2.5
|
||||
|
||||
- [ ] Background sync worker (автоматическая синхронизация каждые N минут)
|
||||
- [ ] Conflict resolution (при конфликтах обновления)
|
||||
- [ ] UI: pending counter в header
|
||||
- [ ] UI: manual sync button
|
||||
- [ ] UI: conflict alerts
|
||||
- [ ] Retry logic для failed pending changes
|
||||
- [ ] RefreshPrices для local mode (через local_components)
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Compile
|
||||
go build ./cmd/qfs
|
||||
|
||||
# Run
|
||||
./quoteforge
|
||||
|
||||
# Check pending changes
|
||||
curl http://localhost:8080/api/sync/pending/count
|
||||
|
||||
# Manual sync
|
||||
curl -X POST http://localhost:8080/api/sync/push
|
||||
```
|
||||
@@ -1,121 +0,0 @@
|
||||
# Миграция: Функционал пересчета цен в конфигураторе
|
||||
|
||||
## Описание изменений
|
||||
|
||||
Добавлен функционал автоматического обновления цен компонентов в сохраненных конфигурациях.
|
||||
|
||||
### Новые возможности
|
||||
|
||||
1. **Кнопка "Пересчитать цену"** на странице конфигуратора
|
||||
- Обновляет цены всех компонентов в конфигурации до актуальных значений из базы данных
|
||||
- Сохраняет количество компонентов, обновляя только цены
|
||||
- Отображает время последнего обновления цен
|
||||
|
||||
2. **Поле `price_updated_at`** в таблице конфигураций
|
||||
- Хранит дату и время последнего обновления цен
|
||||
- Отображается на странице конфигуратора в удобном формате ("5 мин. назад", "2 ч. назад" и т.д.)
|
||||
|
||||
### Изменения в базе данных
|
||||
|
||||
Добавлено новое поле в таблицу `qt_configurations`:
|
||||
```sql
|
||||
ALTER TABLE qt_configurations
|
||||
ADD COLUMN price_updated_at TIMESTAMP NULL DEFAULT NULL
|
||||
AFTER server_count;
|
||||
```
|
||||
|
||||
### Новый API endpoint
|
||||
|
||||
```
|
||||
POST /api/configs/:uuid/refresh-prices
|
||||
```
|
||||
|
||||
**Требования:**
|
||||
- Авторизация: Bearer Token
|
||||
- Роль: editor или выше
|
||||
|
||||
**Ответ:**
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"uuid": "...",
|
||||
"name": "Конфигурация 1",
|
||||
"items": [
|
||||
{
|
||||
"lot_name": "CPU_AMD_9654",
|
||||
"quantity": 2,
|
||||
"unit_price": 11500.00
|
||||
}
|
||||
],
|
||||
"total_price": 23000.00,
|
||||
"price_updated_at": "2026-01-31T12:34:56Z",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Применение изменений
|
||||
|
||||
### 1. Обновление базы данных
|
||||
|
||||
Запустите сервер с флагом миграции:
|
||||
```bash
|
||||
./quoteforge -migrate -config config.yaml
|
||||
```
|
||||
|
||||
Или выполните SQL миграцию вручную:
|
||||
```bash
|
||||
mysql -u user -p RFQ_LOG < migrations/004_add_price_updated_at.sql
|
||||
```
|
||||
|
||||
### 2. Перезапуск сервера
|
||||
|
||||
После применения миграции перезапустите сервер:
|
||||
```bash
|
||||
./quoteforge -config config.yaml
|
||||
```
|
||||
|
||||
## Использование
|
||||
|
||||
1. Откройте любую сохраненную конфигурацию в конфигураторе
|
||||
2. Нажмите кнопку **"Пересчитать цену"** рядом с кнопкой "Сохранить"
|
||||
3. Все цены компонентов будут обновлены до актуальных значений
|
||||
4. Конфигурация автоматически сохраняется с обновленными ценами
|
||||
5. Под кнопками отображается время последнего обновления цен
|
||||
|
||||
## Технические детали
|
||||
|
||||
### Измененные файлы
|
||||
|
||||
- `internal/models/configuration.go` - добавлено поле `PriceUpdatedAt`
|
||||
- `internal/services/configuration.go` - добавлен метод `RefreshPrices()`
|
||||
- `internal/handlers/configuration.go` - добавлен обработчик `RefreshPrices()`
|
||||
- `cmd/qfs/main.go` - добавлен маршрут `/api/configs/:uuid/refresh-prices`
|
||||
- `web/templates/index.html` - добавлена кнопка и JavaScript функции
|
||||
- `migrations/004_add_price_updated_at.sql` - SQL миграция
|
||||
- `CLAUDE.md` - обновлена документация
|
||||
|
||||
### Логика обновления цен
|
||||
|
||||
1. Получение конфигурации по UUID
|
||||
2. Проверка прав доступа (пользователь должен быть владельцем)
|
||||
3. Для каждого компонента в конфигурации:
|
||||
- Получение актуальной цены из `qt_lot_metadata.current_price`
|
||||
- Обновление `unit_price` в items
|
||||
4. Пересчет `total_price` с учетом `server_count`
|
||||
5. Установка `price_updated_at` на текущее время
|
||||
6. Сохранение конфигурации
|
||||
|
||||
### Обработка ошибок
|
||||
|
||||
- Если компонент не найден или у него нет цены - сохраняется старая цена
|
||||
- При ошибках доступа возвращается 403 Forbidden
|
||||
- При отсутствии конфигурации возвращается 404 Not Found
|
||||
|
||||
## Отмена изменений (Rollback)
|
||||
|
||||
Для отмены миграции выполните:
|
||||
```sql
|
||||
ALTER TABLE qt_configurations DROP COLUMN price_updated_at;
|
||||
```
|
||||
|
||||
**Внимание:** После отмены миграции функционал пересчета цен перестанет работать корректно.
|
||||
9
Makefile
9
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: build build-release clean test run version
|
||||
.PHONY: build build-release clean test run version install-hooks
|
||||
|
||||
# Get version from git
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
@@ -72,6 +72,12 @@ 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"
|
||||
@@ -92,6 +98,7 @@ 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)"
|
||||
|
||||
432
README.md
432
README.md
@@ -1,436 +1,66 @@
|
||||
# QuoteForge
|
||||
|
||||
**Server Configuration & Quotation Tool**
|
||||
**Корпоративный конфигуратор серверов и расчёт КП**
|
||||
|
||||
QuoteForge — корпоративный инструмент для конфигурирования серверов и формирования коммерческих предложений (КП).
|
||||
Приложение работает в strict local-first режиме: пользовательские операции выполняются через локальную SQLite, MariaDB используется для синхронизации и серверного администрирования прайслистов.
|
||||
Offline-first архитектура: пользовательские операции через локальную SQLite, MariaDB только для синхронизации.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## Возможности
|
||||
---
|
||||
|
||||
### Для пользователей
|
||||
- 📱 **Mobile-first интерфейс** — удобная работа с телефона и планшета
|
||||
- 🖥️ **Конфигуратор серверов** — пошаговый выбор компонентов с проверкой совместимости
|
||||
- 💰 **Автоматический расчёт цен** — актуальные цены на основе истории закупок
|
||||
- 📊 **Экспорт в CSV/XLSX** — готовые спецификации для клиентов
|
||||
- 💾 **Сохранение конфигураций** — история и шаблоны для повторного использования
|
||||
- 🔌 **Полная офлайн-работа** — можно продолжать работу без сети и синхронизировать позже
|
||||
- 🛡️ **Защищенная синхронизация** — sync блокируется preflight-проверкой, если локальная схема не готова
|
||||
## Документация
|
||||
|
||||
### Для ценовых администраторов
|
||||
- 📈 **Умный расчёт цен** — медиана, взвешенная медиана, среднее
|
||||
- 🎯 **Система алертов** — уведомления о популярных компонентах с устаревшими ценами
|
||||
- 📉 **Аналитика использования** — какие компоненты востребованы в КП
|
||||
- ⚙️ **Гибкие настройки** — периоды расчёта, методы, ручные переопределения
|
||||
Полная архитектурная документация хранится в **[bible/](bible/README.md)**:
|
||||
|
||||
### Индикация актуальности цен
|
||||
| Цвет | Статус | Условие |
|
||||
|------|--------|---------|
|
||||
| 🟢 Зелёный | Свежая | < 30 дней, ≥ 3 источника |
|
||||
| 🟡 Жёлтый | Нормальная | 30-60 дней |
|
||||
| 🟠 Оранжевый | Устаревающая | 60-90 дней |
|
||||
| 🔴 Красный | Устаревшая | > 90 дней или нет данных |
|
||||
| Файл | Тема |
|
||||
|------|------|
|
||||
| [bible/01-overview.md](bible/01-overview.md) | Продукт, возможности, технологии, структура репо |
|
||||
| [bible/02-architecture.md](bible/02-architecture.md) | Local-first, sync, ценообразование, версионность |
|
||||
| [bible/03-database.md](bible/03-database.md) | SQLite и MariaDB схемы, права, миграции |
|
||||
| [bible/04-api.md](bible/04-api.md) | Все API endpoints и web-маршруты |
|
||||
| [bible/05-config.md](bible/05-config.md) | Конфигурация, env vars, установка |
|
||||
| [bible/06-backup.md](bible/06-backup.md) | Резервное копирование |
|
||||
| [bible/07-dev.md](bible/07-dev.md) | Команды разработки, стиль кода, guardrails |
|
||||
|
||||
## Технологии
|
||||
---
|
||||
|
||||
- **Backend:** Go 1.22+, Gin, GORM
|
||||
- **Frontend:** HTML, Tailwind CSS, htmx
|
||||
- **Database:** SQLite (runtime/local-first), MariaDB 11+ (sync + server admin)
|
||||
- **Export:** excelize (XLSX), encoding/csv
|
||||
|
||||
## Требования
|
||||
|
||||
- Go 1.22 или выше
|
||||
- MariaDB 11.x (или MySQL 8.x)
|
||||
- ~50 MB дискового пространства
|
||||
|
||||
## Установка
|
||||
|
||||
### 1. Клонирование репозитория
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-company/quoteforge.git
|
||||
cd quoteforge
|
||||
```
|
||||
|
||||
### 2. Настройка конфигурации
|
||||
|
||||
```bash
|
||||
cp config.example.yaml config.yaml
|
||||
```
|
||||
|
||||
Отредактируйте `config.yaml`:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
mode: "release"
|
||||
|
||||
database:
|
||||
host: "localhost"
|
||||
port: 3306
|
||||
name: "RFQ_LOG"
|
||||
user: "quoteforge"
|
||||
password: "your-secure-password"
|
||||
|
||||
auth:
|
||||
jwt_secret: "your-jwt-secret-min-32-chars"
|
||||
token_expiry: "24h"
|
||||
```
|
||||
|
||||
### 3. Миграции базы данных
|
||||
## Быстрый старт
|
||||
|
||||
```bash
|
||||
# Применить миграции
|
||||
go run ./cmd/qfs -migrate
|
||||
```
|
||||
|
||||
### Мигратор OPS -> проекты (preview/apply)
|
||||
|
||||
Переносит квоты, чьи названия начинаются с `OPS-xxxx` (где `x` — цифра), в проект `OPS-xxxx`.
|
||||
Если проекта нет, он будет создан; если архивный — реактивирован.
|
||||
|
||||
Сначала всегда смотрите preview:
|
||||
|
||||
```bash
|
||||
go run ./cmd/migrate_ops_projects -config config.yaml
|
||||
```
|
||||
|
||||
Применение изменений:
|
||||
|
||||
```bash
|
||||
go run ./cmd/migrate_ops_projects -config config.yaml -apply
|
||||
```
|
||||
|
||||
Без интерактивного подтверждения:
|
||||
|
||||
```bash
|
||||
go run ./cmd/migrate_ops_projects -config config.yaml -apply -yes
|
||||
```
|
||||
|
||||
### Минимальные права БД для пользователя квотаций
|
||||
|
||||
Если нужен пользователь, который может работать с конфигурациями, но не может создавать/удалять прайслисты:
|
||||
|
||||
```sql
|
||||
-- 1) Создать пользователя (если его ещё нет)
|
||||
CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY 'DB_PASSWORD_PLACEHOLDER';
|
||||
|
||||
-- 2) Если пользователь уже существовал, принудительно обновить пароль
|
||||
ALTER USER 'quote_user'@'%' IDENTIFIED BY 'DB_PASSWORD_PLACEHOLDER';
|
||||
|
||||
-- 3) (Опционально, но рекомендуется) удалить дубли пользователя с другими host,
|
||||
-- чтобы не возникало конфликтов вида user@localhost vs user@'%'
|
||||
DROP USER IF EXISTS 'quote_user'@'localhost';
|
||||
DROP USER IF EXISTS 'quote_user'@'127.0.0.1';
|
||||
DROP USER IF EXISTS 'quote_user'@'::1';
|
||||
|
||||
-- 4) Сбросить лишние права
|
||||
REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'quote_user'@'%';
|
||||
|
||||
-- 5) Чтение данных для конфигуратора и синка
|
||||
GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%';
|
||||
|
||||
-- 6) Работа с конфигурациями
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
|
||||
|
||||
FLUSH PRIVILEGES;
|
||||
|
||||
SHOW GRANTS FOR 'quote_user'@'%';
|
||||
SHOW CREATE USER 'quote_user'@'%';
|
||||
```
|
||||
|
||||
Полный набор прав для пользователя квотаций:
|
||||
|
||||
```sql
|
||||
GRANT USAGE ON *.* TO 'quote_user'@'%' IDENTIFIED BY 'DB_PASSWORD_PLACEHOLDER';
|
||||
GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
|
||||
```
|
||||
|
||||
Важно:
|
||||
- не выдавайте `INSERT/UPDATE/DELETE` на `qt_pricelists` и `qt_pricelist_items`, если пользователь не должен управлять прайслистами;
|
||||
- если видите ошибку `Access denied for user ...@'<ip>'`, проверьте, что не осталось других записей `quote_user@host` кроме `quote_user@'%'`;
|
||||
- после смены DB-настроек через `/setup` приложение перезапускается автоматически и подхватывает нового пользователя.
|
||||
|
||||
### 4. Импорт метаданных компонентов
|
||||
|
||||
```bash
|
||||
go run ./cmd/importer
|
||||
```
|
||||
|
||||
### 5. Запуск
|
||||
|
||||
```bash
|
||||
# Development
|
||||
# Запустить
|
||||
go run ./cmd/qfs
|
||||
|
||||
# Production (with Makefile - recommended)
|
||||
make build-release # Builds with version info
|
||||
./bin/qfs -version # Check version
|
||||
|
||||
# Production (manual)
|
||||
VERSION=$(git describe --tags --always --dirty)
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=$VERSION" -o bin/qfs ./cmd/qfs
|
||||
./bin/qfs -version
|
||||
# или
|
||||
make run
|
||||
```
|
||||
|
||||
**Makefile команды:**
|
||||
```bash
|
||||
make build-release # Оптимизированная сборка с версией
|
||||
make build-all # Сборка для всех платформ (Linux, macOS, Windows)
|
||||
make build-windows # Только для Windows
|
||||
make run # Запуск dev сервера
|
||||
make test # Запуск тестов
|
||||
make clean # Очистка bin/
|
||||
make help # Показать все команды
|
||||
```
|
||||
|
||||
Приложение будет доступно по адресу: http://localhost:8080
|
||||
|
||||
### Локальная SQLite база (state)
|
||||
|
||||
Локальная база приложения хранится в профиле пользователя и не зависит от расположения бинарника.
|
||||
Имя файла: `qfs.db`.
|
||||
|
||||
- macOS: `~/Library/Application Support/QuoteForge/qfs.db`
|
||||
- Linux: `$XDG_STATE_HOME/quoteforge/qfs.db` (или `~/.local/state/quoteforge/qfs.db`)
|
||||
- Windows: `%LOCALAPPDATA%\\QuoteForge\\qfs.db`
|
||||
|
||||
Можно переопределить путь через `-localdb` или переменную окружения `QFS_DB_PATH`.
|
||||
|
||||
#### Sync readiness guard
|
||||
|
||||
Перед `push/pull` выполняется preflight-проверка:
|
||||
- доступен ли сервер (MariaDB);
|
||||
- можно ли проверить и применить централизованные миграции локальной БД;
|
||||
- подходит ли версия приложения под `min_app_version` миграций.
|
||||
|
||||
Если проверка не пройдена:
|
||||
- локальная работа (CRUD) продолжается;
|
||||
- sync API возвращает `423 Locked` с `reason_code` и `reason_text`;
|
||||
- в UI показывается красный индикатор и причина блокировки в модалке синхронизации.
|
||||
|
||||
### Версионность конфигураций (local-first)
|
||||
|
||||
Для `local_configurations` используется append-only versioning через полные snapshot-версии:
|
||||
|
||||
- таблица: `local_configuration_versions`
|
||||
- для каждого изменения создаётся новая версия (`version_no = max + 1`)
|
||||
- `local_configurations.current_version_id` указывает на активную версию
|
||||
- старые версии не изменяются и не удаляются в обычном потоке
|
||||
- rollback не "перематывает" историю, а создаёт новую версию из выбранного snapshot
|
||||
|
||||
При backfill (миграция `006_add_local_configuration_versions.sql`) для существующих конфигураций создаётся `v1` и проставляется `current_version_id`.
|
||||
|
||||
#### Rollback
|
||||
|
||||
Rollback выполняется API-методом:
|
||||
Приложение: http://localhost:8080 → откроется `/setup` для настройки подключения к MariaDB.
|
||||
|
||||
```bash
|
||||
POST /api/configs/:uuid/rollback
|
||||
{
|
||||
"target_version": 3,
|
||||
"note": "optional"
|
||||
}
|
||||
# Сборка
|
||||
make build-release
|
||||
|
||||
# Проверка
|
||||
go build ./cmd/qfs && go vet ./...
|
||||
```
|
||||
|
||||
Результат:
|
||||
- создаётся новая версия `vN` с `data` из целевой версии
|
||||
- `change_note = "rollback to v{target_version}"` (+ note, если передан)
|
||||
- `current_version_id` переключается на новую версию
|
||||
- конфигурация уходит в `sync_status = pending`
|
||||
---
|
||||
|
||||
### Локальный config.yaml
|
||||
## Releases & Changelog
|
||||
|
||||
По умолчанию `qfs` ищет `config.yaml` в той же user-state папке, где лежит `qfs.db` (а не рядом с бинарником).
|
||||
Можно переопределить путь через `-config` или `QFS_CONFIG_PATH`.
|
||||
Changelog между версиями: `releases/memory/v{major}.{minor}.{patch}.md`
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
# Сборка образа
|
||||
docker build -t quoteforge .
|
||||
|
||||
# Запуск с docker-compose
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
quoteforge/
|
||||
├── cmd/
|
||||
│ ├── server/main.go # Main HTTP server
|
||||
│ └── importer/main.go # Import metadata from lot table
|
||||
├── internal/
|
||||
│ ├── config/ # Конфигурация
|
||||
│ ├── models/ # GORM модели
|
||||
│ ├── handlers/ # HTTP handlers
|
||||
│ ├── services/ # Бизнес-логика
|
||||
│ ├── middleware/ # Auth, CORS, etc.
|
||||
│ └── repository/ # Работа с БД
|
||||
├── web/
|
||||
│ ├── templates/ # HTML шаблоны
|
||||
│ └── static/ # CSS, JS, изображения
|
||||
├── migrations/ # SQL миграции
|
||||
├── config.yaml # Конфигурация
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
└── go.mod
|
||||
```
|
||||
|
||||
## Роли пользователей
|
||||
|
||||
| Роль | Описание |
|
||||
|------|----------|
|
||||
| `viewer` | Просмотр, создание квот, экспорт |
|
||||
| `editor` | + сохранение конфигураций |
|
||||
| `pricing_admin` | + управление ценами и алертами |
|
||||
| `admin` | Полный доступ, управление пользователями |
|
||||
|
||||
## API
|
||||
|
||||
Документация API доступна по адресу `/api/docs` (в разработке).
|
||||
|
||||
Основные endpoints:
|
||||
|
||||
```
|
||||
POST /api/auth/login # Авторизация
|
||||
GET /api/components # Список компонентов
|
||||
POST /api/quote/calculate # Расчёт цены
|
||||
POST /api/export/xlsx # Экспорт в Excel
|
||||
GET /api/configs # Сохранённые конфигурации
|
||||
GET /api/configs/:uuid/versions # Список версий конфигурации
|
||||
GET /api/configs/:uuid/versions/:version # Получить конкретную версию
|
||||
POST /api/configs/:uuid/rollback # Rollback на указанную версию
|
||||
POST /api/configs/:uuid/reactivate # Вернуть архивную конфигурацию в активные
|
||||
GET /api/sync/readiness # Статус readiness guard (ready|blocked|unknown)
|
||||
GET /api/sync/status # Сводный статус синхронизации
|
||||
GET /api/sync/info # Данные для модалки синхронизации
|
||||
POST /api/sync/push # Push pending changes (423, если blocked)
|
||||
POST /api/sync/all # Full sync push+pull (423, если blocked)
|
||||
POST /api/sync/components # Pull components (423, если blocked)
|
||||
POST /api/sync/pricelists # Pull pricelists (423, если blocked)
|
||||
```
|
||||
|
||||
#### Sync payload для versioning
|
||||
|
||||
События в `pending_changes` для конфигураций содержат:
|
||||
- `configuration_uuid`
|
||||
- `operation` (`create` / `update` / `rollback`)
|
||||
- `current_version_id` и `current_version_no`
|
||||
- `snapshot` (текущее состояние конфигурации)
|
||||
- `idempotency_key` и `conflict_policy` (`last_write_wins`)
|
||||
|
||||
Это позволяет push-слою отправлять на сервер актуальное состояние и готовит основу для будущего conflict resolution.
|
||||
|
||||
## Cron Jobs
|
||||
|
||||
QuoteForge now includes automated cron jobs for maintenance tasks. These can be run using the built-in cron functionality in the Docker container.
|
||||
|
||||
### Docker Compose Setup
|
||||
|
||||
The Docker setup includes a dedicated cron service that runs the following jobs:
|
||||
|
||||
- **Alerts check**: Every hour (0 * * * *)
|
||||
- **Price updates**: Daily at 2 AM (0 2 * * *)
|
||||
- **Usage counter reset**: Weekly on Sunday at 1 AM (0 1 * * 0)
|
||||
- **Popularity score updates**: Daily at 3 AM (0 3 * * *)
|
||||
|
||||
To enable cron jobs in Docker, run:
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Manual Cron Job Execution
|
||||
|
||||
You can also run cron jobs manually using the quoteforge-cron binary:
|
||||
|
||||
```bash
|
||||
# Check and generate alerts
|
||||
go run ./cmd/cron -job=alerts
|
||||
|
||||
# Recalculate all prices
|
||||
go run ./cmd/cron -job=update-prices
|
||||
|
||||
# Reset usage counters
|
||||
go run ./cmd/cron -job=reset-counters
|
||||
|
||||
# Update popularity scores
|
||||
go run ./cmd/cron -job=update-popularity
|
||||
```
|
||||
|
||||
### Cron Job Details
|
||||
|
||||
- **Alerts check**: Generates alerts for components with high demand and stale prices, trending components without prices, and components with no recent quotes
|
||||
- **Price updates**: Recalculates prices for all components using configured methods (median, weighted median, average)
|
||||
- **Usage counter reset**: Resets weekly and monthly usage counters for components
|
||||
- **Popularity score updates**: Recalculates popularity scores based on supplier quote activity
|
||||
|
||||
## Разработка
|
||||
|
||||
```bash
|
||||
# Запуск в режиме разработки (hot reload)
|
||||
go run ./cmd/qfs
|
||||
|
||||
# Запуск тестов
|
||||
go test ./...
|
||||
|
||||
# Сборка для Linux
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/qfs ./cmd/qfs
|
||||
```
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
| Переменная | Описание | По умолчанию |
|
||||
|------------|----------|--------------|
|
||||
| `QF_DB_HOST` | Хост базы данных | localhost |
|
||||
| `QF_DB_PORT` | Порт базы данных | 3306 |
|
||||
| `QF_DB_NAME` | Имя базы данных | RFQ_LOG |
|
||||
| `QF_DB_USER` | Пользователь БД | — |
|
||||
| `QF_DB_PASSWORD` | Пароль БД | — |
|
||||
| `QF_JWT_SECRET` | Секрет для JWT | — |
|
||||
| `QF_SERVER_PORT` | Порт сервера | 8080 |
|
||||
| `QFS_DB_PATH` | Полный путь к локальной SQLite БД | OS-specific user state dir |
|
||||
| `QFS_STATE_DIR` | Каталог state (если `QFS_DB_PATH` не задан) | OS-specific user state dir |
|
||||
| `QFS_CONFIG_PATH` | Полный путь к `config.yaml` | OS-specific user state dir |
|
||||
|
||||
## Интеграция с существующей БД
|
||||
|
||||
QuoteForge интегрируется с существующей базой RFQ_LOG:
|
||||
|
||||
- `lot` — справочник компонентов (только чтение)
|
||||
- `lot_log` — история цен от поставщиков (только чтение)
|
||||
- `supplier` — справочник поставщиков (только чтение)
|
||||
|
||||
Новые таблицы QuoteForge имеют префикс `qt_`:
|
||||
|
||||
- `qt_users` — пользователи приложения
|
||||
- `qt_lot_metadata` — расширенные данные компонентов
|
||||
- `qt_configurations` — сохранённые конфигурации
|
||||
- `qt_pricing_alerts` — алерты для администраторов
|
||||
---
|
||||
|
||||
## Поддержка
|
||||
|
||||
По вопросам работы приложения обращайтесь:
|
||||
- Email: mike@mchus.pro
|
||||
- Internal: @mchus
|
||||
|
||||
## Лицензия
|
||||
|
||||
Данное программное обеспечение является собственностью компании и предназначено исключительно для внутреннего использования. Распространение, копирование или модификация без письменного разрешения запрещены.
|
||||
|
||||
См. файл [LICENSE](LICENSE) для подробностей.
|
||||
Собственность компании, только для внутреннего использования. См. [LICENSE](LICENSE).
|
||||
|
||||
119
bible/01-overview.md
Normal file
119
bible/01-overview.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# 01 — Product Overview
|
||||
|
||||
## What is QuoteForge
|
||||
|
||||
A corporate server configuration and quotation tool.
|
||||
Operates in **strict local-first** mode: all user operations go through local SQLite; MariaDB is used only for synchronization.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### For Users
|
||||
- Mobile-first interface — works comfortably on phones and tablets
|
||||
- Server configurator — step-by-step component selection
|
||||
- Automatic price calculation — based on pricelists from local cache
|
||||
- CSV export — ready-to-use specifications for clients
|
||||
- Configuration history — versioned snapshots with rollback support
|
||||
- Full offline operation — continue working without network, sync later
|
||||
- Guarded synchronization — sync is blocked by preflight check if local schema is not ready
|
||||
|
||||
### User Roles
|
||||
|
||||
| Role | Permissions |
|
||||
|------|-------------|
|
||||
| `viewer` | View, create quotes, export |
|
||||
| `editor` | + save configurations |
|
||||
| `pricing_admin` | + manage prices and alerts |
|
||||
| `admin` | Full access, user management |
|
||||
|
||||
### Price Freshness Indicators
|
||||
|
||||
| Color | Status | Condition |
|
||||
|-------|--------|-----------|
|
||||
| Green | Fresh | < 30 days, ≥ 3 sources |
|
||||
| Yellow | Normal | 30–60 days |
|
||||
| Orange | Aging | 60–90 days |
|
||||
| Red | Stale | > 90 days or no data |
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Stack |
|
||||
|-------|-------|
|
||||
| Backend | Go 1.22+, Gin, GORM |
|
||||
| Frontend | HTML, Tailwind CSS, htmx |
|
||||
| Local DB | SQLite (`qfs.db`) |
|
||||
| Server DB | MariaDB 11+ (sync + server admin) |
|
||||
| Export | encoding/csv, excelize (XLSX) |
|
||||
|
||||
---
|
||||
|
||||
## Product Scope
|
||||
|
||||
**In scope:**
|
||||
- Component configurator and quotation calculation
|
||||
- Projects and configurations
|
||||
- Read-only pricelist viewing from local cache
|
||||
- Sync (pull components/pricelists, push local changes)
|
||||
|
||||
**Out of scope (removed intentionally — do not restore):**
|
||||
- Admin pricing UI/API
|
||||
- Stock import
|
||||
- Alerts
|
||||
- Cron/importer utilities
|
||||
|
||||
---
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
quoteforge/
|
||||
├── cmd/
|
||||
│ ├── qfs/main.go # HTTP server entry point
|
||||
│ ├── migrate/ # Migration tool
|
||||
│ └── migrate_ops_projects/ # OPS project migrator
|
||||
├── internal/
|
||||
│ ├── appmeta/ # App version metadata
|
||||
│ ├── appstate/ # State management, backup
|
||||
│ ├── article/ # Article generation
|
||||
│ ├── config/ # Config parsing
|
||||
│ ├── db/ # DB initialization
|
||||
│ ├── handlers/ # HTTP handlers
|
||||
│ ├── localdb/ # SQLite layer
|
||||
│ ├── lotmatch/ # Lot matching logic
|
||||
│ ├── middleware/ # Auth, CORS, etc.
|
||||
│ ├── models/ # GORM models
|
||||
│ ├── repository/ # Repository layer
|
||||
│ └── services/ # Business logic
|
||||
├── web/
|
||||
│ ├── templates/ # HTML templates + partials
|
||||
│ └── static/ # CSS, JS, assets
|
||||
├── migrations/ # SQL migration files (30+)
|
||||
├── bible/ # Architectural documentation (this section)
|
||||
├── releases/memory/ # Per-version changelogs
|
||||
├── config.example.yaml # Config template (the only one in repo)
|
||||
└── go.mod
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration with Existing DB
|
||||
|
||||
QuoteForge integrates with the existing `RFQ_LOG` database:
|
||||
|
||||
**Read-only:**
|
||||
- `lot` — component catalog
|
||||
- `qt_lot_metadata` — extended component data
|
||||
- `qt_categories` — categories
|
||||
- `qt_pricelists`, `qt_pricelist_items` — pricelists
|
||||
|
||||
**Read + Write:**
|
||||
- `qt_configurations` — configurations
|
||||
- `qt_projects` — projects
|
||||
|
||||
**Sync service tables:**
|
||||
- `qt_client_local_migrations` — migration catalog (SELECT only)
|
||||
- `qt_client_schema_state` — applied migrations state
|
||||
- `qt_pricelist_sync_status` — pricelist sync status
|
||||
220
bible/02-architecture.md
Normal file
220
bible/02-architecture.md
Normal file
@@ -0,0 +1,220 @@
|
||||
# 02 — Architecture
|
||||
|
||||
## Local-First Principle
|
||||
|
||||
**SQLite** is the single source of truth for the user.
|
||||
**MariaDB** is a sync server only — it never blocks local operations.
|
||||
|
||||
```
|
||||
User
|
||||
│
|
||||
▼
|
||||
SQLite (qfs.db) ← all CRUD operations go here
|
||||
│
|
||||
│ background sync (every 5 min)
|
||||
▼
|
||||
MariaDB (RFQ_LOG) ← pull/push only
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- All CRUD operations go through SQLite only
|
||||
- If MariaDB is unavailable → local work continues without restrictions
|
||||
- Changes are queued in `pending_changes` and pushed on next sync
|
||||
|
||||
---
|
||||
|
||||
## Synchronization
|
||||
|
||||
### Data Flow Diagram
|
||||
|
||||
```
|
||||
[ SERVER / MariaDB ]
|
||||
┌───────────────────────────┐
|
||||
│ qt_projects │
|
||||
│ qt_configurations │
|
||||
│ qt_pricelists │
|
||||
│ qt_pricelist_items │
|
||||
│ qt_pricelist_sync_status │
|
||||
└─────────────┬─────────────┘
|
||||
│
|
||||
pull (projects/configs/pricelists)
|
||||
│
|
||||
┌────────────────────┴────────────────────┐
|
||||
│ │
|
||||
[ CLIENT A / SQLite ] [ CLIENT B / SQLite ]
|
||||
local_projects local_projects
|
||||
local_configurations local_configurations
|
||||
local_pricelists local_pricelists
|
||||
local_pricelist_items local_pricelist_items
|
||||
pending_changes pending_changes
|
||||
│ │
|
||||
└────── push (projects/configs only) ─────┘
|
||||
│
|
||||
[ SERVER / MariaDB ]
|
||||
```
|
||||
|
||||
### Sync Direction by Entity
|
||||
|
||||
| Entity | Direction |
|
||||
|--------|-----------|
|
||||
| Configurations | Client ↔ Server ↔ Other Clients |
|
||||
| Projects | Client ↔ Server ↔ Other Clients |
|
||||
| Pricelists | Server → Clients only (no push) |
|
||||
| Components | Server → Clients only |
|
||||
|
||||
Local pricelists not present on the server and not referenced by active configurations are deleted automatically on sync.
|
||||
|
||||
### Soft Deletes (Archive Pattern)
|
||||
|
||||
Configurations and projects are **never hard-deleted**. Deletion is archive via `is_active = false`.
|
||||
|
||||
- `DELETE /api/configs/:uuid` → sets `is_active = false` (archived); can be restored via `reactivate`
|
||||
- `DELETE /api/projects/:uuid` → archives a project **variant** only (`variant` field must be non-empty); main projects cannot be deleted via this endpoint
|
||||
|
||||
## Sync Readiness Guard
|
||||
|
||||
Before every push/pull, a preflight check runs:
|
||||
1. Is the server (MariaDB) reachable?
|
||||
2. Can centralized local DB migrations be applied?
|
||||
3. Does the application version satisfy `min_app_version` of pending migrations?
|
||||
|
||||
**If the check fails:**
|
||||
- Local CRUD continues without restriction
|
||||
- Sync API returns `423 Locked` with `reason_code` and `reason_text`
|
||||
- UI shows a red indicator with the block reason
|
||||
|
||||
---
|
||||
|
||||
## Pricing
|
||||
|
||||
### Principle
|
||||
|
||||
**Prices come only from `local_pricelist_items`.**
|
||||
Components (`local_components`) are metadata-only — they contain no pricing information.
|
||||
|
||||
### Lookup Pattern
|
||||
|
||||
```go
|
||||
// Look up a price for a line item
|
||||
price, found := s.lookupPriceByPricelistID(pricelistID, lotName)
|
||||
if found && price > 0 {
|
||||
// use price
|
||||
}
|
||||
|
||||
// Inside lookupPriceByPricelistID:
|
||||
localPL, err := s.localDB.GetLocalPricelistByServerID(pricelistID)
|
||||
price, err := s.localDB.GetLocalPriceForLot(localPL.ID, lotName)
|
||||
```
|
||||
|
||||
### Multi-Level Pricelists
|
||||
|
||||
A configuration can reference up to three pricelists simultaneously:
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
| `pricelist_id` | Primary (estimate) |
|
||||
| `warehouse_pricelist_id` | Warehouse pricing |
|
||||
| `competitor_pricelist_id` | Competitor pricing |
|
||||
|
||||
Pricelist sources: `estimate` | `warehouse` | `competitor`
|
||||
|
||||
### "Auto" Pricelist Selection
|
||||
|
||||
Configurator supports explicit and automatic selection per source (`estimate`, `warehouse`, `competitor`):
|
||||
|
||||
- **Explicit mode:** concrete `pricelist_id` is set by user in settings.
|
||||
- **Auto mode:** client sends no explicit ID for that source; backend resolves the current latest active pricelist.
|
||||
|
||||
`auto` must stay `auto` after price-level refresh and after manual "refresh prices":
|
||||
- resolved IDs are runtime-only and must not overwrite user's mode;
|
||||
- switching to explicit selection must clear runtime auto resolution for that source.
|
||||
|
||||
### Latest Pricelist Resolution Rules
|
||||
|
||||
For both server (`qt_pricelists`) and local cache (`local_pricelists`), "latest by source" is resolved with:
|
||||
|
||||
1. only pricelists that have at least one item (`EXISTS ...pricelist_items`);
|
||||
2. deterministic sort: `created_at DESC, id DESC`.
|
||||
|
||||
This prevents selecting empty/incomplete snapshots and removes nondeterministic ties.
|
||||
|
||||
---
|
||||
|
||||
## Configuration Versioning
|
||||
|
||||
### Principle
|
||||
|
||||
Append-only for **spec+price** changes: immutable snapshots are stored in `local_configuration_versions`.
|
||||
|
||||
```
|
||||
local_configurations
|
||||
└── current_version_id ──► local_configuration_versions (v3) ← active
|
||||
local_configuration_versions (v2)
|
||||
local_configuration_versions (v1)
|
||||
```
|
||||
|
||||
- `version_no = max + 1` when configuration **spec+price** changes
|
||||
- Old versions are never modified or deleted in normal flow
|
||||
- Rollback does **not** rewind history — it creates a **new** version from the snapshot
|
||||
- Operational updates (`line_no` reorder, server count, project move, rename)
|
||||
are synced via `pending_changes` but do **not** create a new revision snapshot
|
||||
|
||||
### Rollback
|
||||
|
||||
```bash
|
||||
POST /api/configs/:uuid/rollback
|
||||
{
|
||||
"target_version": 3,
|
||||
"note": "optional comment"
|
||||
}
|
||||
```
|
||||
|
||||
Result:
|
||||
- A new version `vN` is created with `data` from the target version
|
||||
- `change_note = "rollback to v{target_version}"` (+ note if provided)
|
||||
- `current_version_id` is switched to the new version
|
||||
- Configuration moves to `sync_status = pending`
|
||||
|
||||
### Sync Status Flow
|
||||
|
||||
```
|
||||
local → pending → synced
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project Specification Ordering (`Line`)
|
||||
|
||||
- Each project configuration has persistent `line_no` (`10,20,30...`) in both SQLite and MariaDB.
|
||||
- Project list ordering is deterministic:
|
||||
`line_no ASC`, then `created_at DESC`, then `id DESC`.
|
||||
- Drag-and-drop reorder in project UI updates `line_no` for active project configurations.
|
||||
- Reorder writes are queued as configuration `update` events in `pending_changes`
|
||||
without creating new configuration versions.
|
||||
- Backward compatibility: if remote MariaDB schema does not yet include `line_no`,
|
||||
sync falls back to create/update without `line_no` instead of failing.
|
||||
|
||||
---
|
||||
|
||||
## Sync Payload for Versioning
|
||||
|
||||
Events in `pending_changes` for configurations contain:
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `configuration_uuid` | Identifier |
|
||||
| `operation` | `create` / `update` / `rollback` |
|
||||
| `current_version_id` | Active version ID |
|
||||
| `current_version_no` | Version number |
|
||||
| `snapshot` | Current configuration state |
|
||||
| `idempotency_key` | For idempotent push |
|
||||
| `conflict_policy` | `last_write_wins` |
|
||||
|
||||
---
|
||||
|
||||
## Background Processes
|
||||
|
||||
| Process | Interval | What it does |
|
||||
|---------|----------|--------------|
|
||||
| Sync worker | 5 min | push pending + pull all |
|
||||
| Backup scheduler | configurable (`backup.time`) | creates ZIP archives |
|
||||
182
bible/03-database.md
Normal file
182
bible/03-database.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# 03 — Database
|
||||
|
||||
## SQLite (local, client-side)
|
||||
|
||||
File: `qfs.db` in the user-state directory (see [05-config.md](05-config.md)).
|
||||
|
||||
### Tables
|
||||
|
||||
#### Components and Reference Data
|
||||
|
||||
| Table | Purpose | Key Fields |
|
||||
|-------|---------|------------|
|
||||
| `local_components` | Component metadata (NO prices) | `lot_name` (PK), `lot_description`, `category`, `model` |
|
||||
| `connection_settings` | MariaDB connection settings | key-value store |
|
||||
| `app_settings` | Application settings | `key` (PK), `value`, `updated_at` |
|
||||
|
||||
#### Pricelists
|
||||
|
||||
| Table | Purpose | Key Fields |
|
||||
|-------|---------|------------|
|
||||
| `local_pricelists` | Pricelist headers | `id`, `server_id` (unique), `source`, `version`, `created_at` |
|
||||
| `local_pricelist_items` | Pricelist line items ← **sole source of prices** | `id`, `pricelist_id` (FK), `lot_name`, `price`, `lot_category` |
|
||||
|
||||
#### Configurations and Projects
|
||||
|
||||
| Table | Purpose | Key Fields |
|
||||
|-------|---------|------------|
|
||||
| `local_configurations` | Saved configurations | `id`, `uuid` (unique), `items` (JSON), `line_no`, `pricelist_id`, `warehouse_pricelist_id`, `competitor_pricelist_id`, `current_version_id`, `sync_status` |
|
||||
| `local_configuration_versions` | Immutable snapshots (revisions) | `id`, `configuration_id` (FK), `version_no`, `data` (JSON), `change_note`, `created_at` |
|
||||
| `local_projects` | Projects | `id`, `uuid` (unique), `name`, `code`, `sync_status` |
|
||||
|
||||
#### Sync
|
||||
|
||||
| Table | Purpose |
|
||||
|-------|---------|
|
||||
| `pending_changes` | Queue of changes to push to MariaDB |
|
||||
| `local_schema_migrations` | Applied migrations (idempotency guard) |
|
||||
|
||||
---
|
||||
|
||||
### Key SQLite Indexes
|
||||
|
||||
```sql
|
||||
-- Pricelists
|
||||
INDEX local_pricelist_items(pricelist_id)
|
||||
UNIQUE INDEX local_pricelists(server_id)
|
||||
INDEX local_pricelists(source, created_at) -- used for "latest by source" queries
|
||||
-- latest-by-source runtime query also applies deterministic tie-break by id DESC
|
||||
|
||||
-- Configurations
|
||||
INDEX local_configurations(pricelist_id)
|
||||
INDEX local_configurations(warehouse_pricelist_id)
|
||||
INDEX local_configurations(competitor_pricelist_id)
|
||||
INDEX local_configurations(project_uuid, line_no) -- project ordering (Line column)
|
||||
UNIQUE INDEX local_configurations(uuid)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `items` JSON Structure in Configurations
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"lot_name": "CPU_AMD_9654",
|
||||
"quantity": 2,
|
||||
"unit_price": 123456.78,
|
||||
"section": "Processors"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Prices are stored inside the `items` JSON field and refreshed from the pricelist on configuration refresh.
|
||||
|
||||
---
|
||||
|
||||
## MariaDB (server-side, sync-only)
|
||||
|
||||
Database: `RFQ_LOG`
|
||||
|
||||
### Tables and Permissions
|
||||
|
||||
| Table | Purpose | Permissions |
|
||||
|-------|---------|-------------|
|
||||
| `lot` | Component catalog | SELECT |
|
||||
| `qt_lot_metadata` | Extended component data | SELECT |
|
||||
| `qt_categories` | Component categories | SELECT |
|
||||
| `qt_pricelists` | Pricelists | SELECT |
|
||||
| `qt_pricelist_items` | Pricelist line items | SELECT |
|
||||
| `qt_configurations` | Saved configurations (includes `line_no`) | SELECT, INSERT, UPDATE |
|
||||
| `lot_partnumbers` | Partnumber → lot mapping (pricelist enrichment) | SELECT |
|
||||
| `stock_log` | Latest stock qty by partnumber (pricelist enrichment) | SELECT |
|
||||
| `qt_projects` | Projects | SELECT, INSERT, UPDATE |
|
||||
| `qt_client_local_migrations` | Migration catalog | SELECT only |
|
||||
| `qt_client_schema_state` | Applied migrations state | SELECT, INSERT, UPDATE |
|
||||
| `qt_pricelist_sync_status` | Pricelist sync status | SELECT, INSERT, UPDATE |
|
||||
|
||||
### Grant Permissions to Existing User
|
||||
|
||||
```sql
|
||||
GRANT SELECT ON RFQ_LOG.lot TO '<DB_USER>'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO '<DB_USER>'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_categories TO '<DB_USER>'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_pricelists TO '<DB_USER>'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO '<DB_USER>'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.lot_partnumbers TO '<DB_USER>'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.stock_log TO '<DB_USER>'@'%';
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO '<DB_USER>'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO '<DB_USER>'@'%';
|
||||
|
||||
GRANT SELECT ON RFQ_LOG.qt_client_local_migrations TO '<DB_USER>'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO '<DB_USER>'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO '<DB_USER>'@'%';
|
||||
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
### Create a New User
|
||||
|
||||
```sql
|
||||
CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY '<DB_PASSWORD>';
|
||||
|
||||
GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.lot_partnumbers TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.stock_log TO 'quote_user'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_client_local_migrations TO 'quote_user'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO 'quote_user'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO 'quote_user'@'%';
|
||||
|
||||
FLUSH PRIVILEGES;
|
||||
SHOW GRANTS FOR 'quote_user'@'%';
|
||||
```
|
||||
|
||||
**Note:** If pricelists sync but show `0` positions (or logs contain `enriching pricelist items with stock` + `SELECT denied`), verify `SELECT` on `lot_partnumbers` and `stock_log` in addition to `qt_pricelist_items`.
|
||||
|
||||
**Note:** If you see `Access denied for user ...@'<ip>'`, check for conflicting user entries (user@localhost vs user@'%').
|
||||
|
||||
---
|
||||
|
||||
## Migrations
|
||||
|
||||
### SQLite Migrations (local)
|
||||
|
||||
- Stored in `migrations/` (SQL files)
|
||||
- Applied via `-migrate` flag or automatically on first run
|
||||
- Idempotent: checked by `id` in `local_schema_migrations`
|
||||
- Already-applied migrations are skipped
|
||||
|
||||
```bash
|
||||
go run ./cmd/qfs -migrate
|
||||
```
|
||||
|
||||
### Centralized Migrations (server-side)
|
||||
|
||||
- Stored in `qt_client_local_migrations` (MariaDB)
|
||||
- Applied automatically during sync readiness check
|
||||
- `min_app_version` — minimum app version required for the migration
|
||||
|
||||
---
|
||||
|
||||
## DB Debugging
|
||||
|
||||
```bash
|
||||
# Inspect schema
|
||||
sqlite3 ~/.local/state/quoteforge/qfs.db ".schema local_components"
|
||||
sqlite3 ~/.local/state/quoteforge/qfs.db ".schema local_configurations"
|
||||
|
||||
# Check pricelist item count
|
||||
sqlite3 ~/.local/state/quoteforge/qfs.db "SELECT COUNT(*) FROM local_pricelist_items"
|
||||
|
||||
# Check pending sync queue
|
||||
sqlite3 ~/.local/state/quoteforge/qfs.db "SELECT COUNT(*) FROM pending_changes"
|
||||
```
|
||||
133
bible/04-api.md
Normal file
133
bible/04-api.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# 04 — API and Web Routes
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Setup
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|--------|----------|---------|
|
||||
| GET | `/setup` | Initial setup page |
|
||||
| POST | `/setup` | Save connection settings |
|
||||
| POST | `/setup/test` | Test MariaDB connection |
|
||||
| GET | `/setup/status` | Setup status |
|
||||
|
||||
### Components
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|--------|----------|---------|
|
||||
| GET | `/api/components` | List components (metadata only) |
|
||||
| GET | `/api/components/:lot_name` | Component by lot_name |
|
||||
| GET | `/api/categories` | List categories |
|
||||
|
||||
### Quote
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|--------|----------|---------|
|
||||
| POST | `/api/quote/validate` | Validate line items |
|
||||
| POST | `/api/quote/calculate` | Calculate quote (prices from pricelist) |
|
||||
| POST | `/api/quote/price-levels` | Prices by level (estimate/warehouse/competitor) |
|
||||
|
||||
### Pricelists (read-only)
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|--------|----------|---------|
|
||||
| GET | `/api/pricelists` | List pricelists (`source`, `active_only`, pagination) |
|
||||
| GET | `/api/pricelists/latest` | Latest pricelist by source |
|
||||
| GET | `/api/pricelists/:id` | Pricelist by ID |
|
||||
| GET | `/api/pricelists/:id/items` | Pricelist line items |
|
||||
| GET | `/api/pricelists/:id/lots` | Lot names in pricelist |
|
||||
|
||||
`GET /api/pricelists?active_only=true` returns only pricelists that have synced items (`item_count > 0`).
|
||||
|
||||
### Configurations
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|--------|----------|---------|
|
||||
| GET | `/api/configs` | List configurations |
|
||||
| POST | `/api/configs` | Create configuration |
|
||||
| GET | `/api/configs/:uuid` | Get configuration |
|
||||
| PUT | `/api/configs/:uuid` | Update configuration |
|
||||
| DELETE | `/api/configs/:uuid` | Archive configuration |
|
||||
| POST | `/api/configs/:uuid/refresh-prices` | Refresh prices from pricelist |
|
||||
| POST | `/api/configs/:uuid/clone` | Clone configuration |
|
||||
| POST | `/api/configs/:uuid/reactivate` | Restore archived configuration |
|
||||
| POST | `/api/configs/:uuid/rename` | Rename configuration |
|
||||
| POST | `/api/configs/preview-article` | Preview generated article for a configuration |
|
||||
| POST | `/api/configs/:uuid/rollback` | Roll back to a version |
|
||||
| GET | `/api/configs/:uuid/versions` | List versions |
|
||||
| GET | `/api/configs/:uuid/versions/:version` | Get specific version |
|
||||
|
||||
`line` field in configuration payloads is backed by persistent `line_no` in DB.
|
||||
|
||||
### Projects
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|--------|----------|---------|
|
||||
| GET | `/api/projects` | List projects |
|
||||
| POST | `/api/projects` | Create project |
|
||||
| GET | `/api/projects/:uuid` | Get project |
|
||||
| PUT | `/api/projects/:uuid` | Update project |
|
||||
| DELETE | `/api/projects/:uuid` | Archive project variant (soft-delete via `is_active=false`; fails if project has no `variant` set — main projects cannot be deleted this way) |
|
||||
| GET | `/api/projects/:uuid/configs` | Project configurations |
|
||||
| PATCH | `/api/projects/:uuid/configs/reorder` | Reorder active project configurations (`ordered_uuids`) and persist `line_no` |
|
||||
|
||||
`GET /api/projects/:uuid/configs` ordering:
|
||||
`line ASC`, then `created_at DESC`, then `id DESC`.
|
||||
|
||||
### Sync
|
||||
|
||||
| Method | Endpoint | Purpose | Flow |
|
||||
|--------|----------|---------|------|
|
||||
| GET | `/api/sync/status` | Overall sync status | read-only |
|
||||
| GET | `/api/sync/readiness` | Preflight status (ready/blocked/unknown) | read-only |
|
||||
| GET | `/api/sync/info` | Data for sync modal | read-only |
|
||||
| GET | `/api/sync/users-status` | Users status | read-only |
|
||||
| GET | `/api/sync/pending` | List pending changes | read-only |
|
||||
| GET | `/api/sync/pending/count` | Count of pending changes | read-only |
|
||||
| POST | `/api/sync/push` | Push pending → MariaDB | SQLite → MariaDB |
|
||||
| POST | `/api/sync/components` | Pull components | MariaDB → SQLite |
|
||||
| POST | `/api/sync/pricelists` | Pull pricelists | MariaDB → SQLite |
|
||||
| POST | `/api/sync/all` | Full sync: push + pull + import | bidirectional |
|
||||
| POST | `/api/sync/repair` | Repair broken entries in pending_changes | SQLite |
|
||||
|
||||
**If sync is blocked by the readiness guard:** all POST sync methods return `423 Locked` with `reason_code` and `reason_text`.
|
||||
|
||||
### Export
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|--------|----------|---------|
|
||||
| POST | `/api/export/csv` | Export configuration to CSV |
|
||||
|
||||
**Export filename format:** `YYYY-MM-DD (ProjectCode) ConfigName Article.csv`
|
||||
(uses `project.Code`, not `project.Name`)
|
||||
|
||||
---
|
||||
|
||||
## Web Routes
|
||||
|
||||
| Route | Page |
|
||||
|-------|------|
|
||||
| `/configs` | Configuration list |
|
||||
| `/configurator` | Configurator |
|
||||
| `/configs/:uuid/revisions` | Configuration revision history |
|
||||
| `/projects` | Project list |
|
||||
| `/projects/:uuid` | Project details |
|
||||
| `/pricelists` | Pricelist list |
|
||||
| `/pricelists/:id` | Pricelist details |
|
||||
| `/setup` | Connection settings |
|
||||
|
||||
---
|
||||
|
||||
## Rollback API (details)
|
||||
|
||||
```bash
|
||||
POST /api/configs/:uuid/rollback
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"target_version": 3,
|
||||
"note": "optional comment"
|
||||
}
|
||||
```
|
||||
|
||||
Response: updated configuration with the new version.
|
||||
129
bible/05-config.md
Normal file
129
bible/05-config.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# 05 — Configuration and Environment
|
||||
|
||||
## File Paths
|
||||
|
||||
### SQLite database (`qfs.db`)
|
||||
|
||||
| OS | Default path |
|
||||
|----|-------------|
|
||||
| macOS | `~/Library/Application Support/QuoteForge/qfs.db` |
|
||||
| Linux | `$XDG_STATE_HOME/quoteforge/qfs.db` or `~/.local/state/quoteforge/qfs.db` |
|
||||
| Windows | `%LOCALAPPDATA%\QuoteForge\qfs.db` |
|
||||
|
||||
Override: `-localdb <path>` or `QFS_DB_PATH`.
|
||||
|
||||
### config.yaml
|
||||
|
||||
Searched in the same user-state directory as `qfs.db` by default.
|
||||
If the file does not exist, it is created automatically.
|
||||
If the format is outdated, it is automatically migrated to the runtime format (`server` + `logging` sections only).
|
||||
|
||||
Override: `-config <path>` or `QFS_CONFIG_PATH`.
|
||||
|
||||
**Important:** `config.yaml` is a runtime user file — it is **not stored in the repository**.
|
||||
`config.example.yaml` is the only config template in the repo.
|
||||
|
||||
---
|
||||
|
||||
## config.yaml Structure
|
||||
|
||||
```yaml
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
mode: "release" # release | debug
|
||||
|
||||
logging:
|
||||
level: "info" # debug | info | warn | error
|
||||
format: "json" # json | text
|
||||
output: "stdout" # stdout | stderr | /path/to/file
|
||||
|
||||
backup:
|
||||
time: "00:00" # HH:MM in local time
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `QFS_DB_PATH` | Full path to SQLite DB | OS-specific user state dir |
|
||||
| `QFS_STATE_DIR` | State directory (if `QFS_DB_PATH` is not set) | OS-specific user state dir |
|
||||
| `QFS_CONFIG_PATH` | Full path to `config.yaml` | OS-specific user state dir |
|
||||
| `QFS_BACKUP_DIR` | Root directory for rotating backups | `<db dir>/backups` |
|
||||
| `QFS_BACKUP_DISABLE` | Disable automatic backups | — |
|
||||
| `QF_DB_HOST` | MariaDB host | localhost |
|
||||
| `QF_DB_PORT` | MariaDB port | 3306 |
|
||||
| `QF_DB_NAME` | Database name | RFQ_LOG |
|
||||
| `QF_DB_USER` | DB user | — |
|
||||
| `QF_DB_PASSWORD` | DB password | — |
|
||||
| `QF_JWT_SECRET` | JWT secret | — |
|
||||
| `QF_SERVER_PORT` | HTTP server port | 8080 |
|
||||
|
||||
`QFS_BACKUP_DISABLE` accepts: `1`, `true`, `yes`.
|
||||
|
||||
---
|
||||
|
||||
## CLI Flags
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `-config <path>` | Path to config.yaml |
|
||||
| `-localdb <path>` | Path to SQLite DB |
|
||||
| `-reset-localdb` | Reset local DB (destructive!) |
|
||||
| `-migrate` | Apply pending migrations and exit |
|
||||
| `-version` | Print version and exit |
|
||||
|
||||
---
|
||||
|
||||
## Installation and First Run
|
||||
|
||||
### Requirements
|
||||
- Go 1.22 or higher
|
||||
- MariaDB 11.x (or MySQL 8.x)
|
||||
- ~50 MB disk space
|
||||
|
||||
### Steps
|
||||
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
git clone <repo-url>
|
||||
cd quoteforge
|
||||
|
||||
# 2. Apply migrations
|
||||
go run ./cmd/qfs -migrate
|
||||
|
||||
# 3. Start
|
||||
go run ./cmd/qfs
|
||||
# or
|
||||
make run
|
||||
```
|
||||
|
||||
Application is available at: http://localhost:8080
|
||||
|
||||
On first run, `/setup` opens for configuring the MariaDB connection.
|
||||
|
||||
### OPS Project Migrator
|
||||
|
||||
Migrates quotes whose names start with `OPS-xxxx` (where `x` is a digit) into a project named `OPS-xxxx`.
|
||||
|
||||
```bash
|
||||
# Preview first (always)
|
||||
go run ./cmd/migrate_ops_projects
|
||||
|
||||
# Apply
|
||||
go run ./cmd/migrate_ops_projects -apply
|
||||
|
||||
# Apply without interactive confirmation
|
||||
go run ./cmd/migrate_ops_projects -apply -yes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker build -t quoteforge .
|
||||
docker-compose up -d
|
||||
```
|
||||
221
bible/06-backup.md
Normal file
221
bible/06-backup.md
Normal file
@@ -0,0 +1,221 @@
|
||||
# 06 — Backup
|
||||
|
||||
## Overview
|
||||
|
||||
Automatic rotating ZIP backup system for local data.
|
||||
|
||||
**What is included in each archive:**
|
||||
- SQLite DB (`qfs.db`)
|
||||
- SQLite sidecars (`qfs.db-wal`, `qfs.db-shm`) if present
|
||||
- `config.yaml` if present
|
||||
|
||||
**Archive name format:** `qfs-backp-YYYY-MM-DD.zip`
|
||||
|
||||
**Retention policy:**
|
||||
| Period | Keep |
|
||||
|--------|------|
|
||||
| Daily | 7 archives |
|
||||
| Weekly | 4 archives |
|
||||
| Monthly | 12 archives |
|
||||
| Yearly | 10 archives |
|
||||
|
||||
**Directories:** `<backup root>/daily`, `/weekly`, `/monthly`, `/yearly`
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
```yaml
|
||||
backup:
|
||||
time: "00:00" # Trigger time in local time (HH:MM format)
|
||||
```
|
||||
|
||||
**Environment variables:**
|
||||
- `QFS_BACKUP_DIR` — backup root directory (default: `<db dir>/backups`)
|
||||
- `QFS_BACKUP_DISABLE` — disable backups (`1/true/yes`)
|
||||
|
||||
---
|
||||
|
||||
## Behavior
|
||||
|
||||
- **At startup:** if no backup exists for the current period, one is created immediately
|
||||
- **Daily:** at the configured time, a new backup is created
|
||||
- **Deduplication:** prevented via a `.period.json` marker file in each period directory
|
||||
- **Rotation:** excess old archives are deleted automatically
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
Module: `internal/appstate/backup.go`
|
||||
|
||||
Main function:
|
||||
```go
|
||||
func EnsureRotatingLocalBackup(dbPath, configPath string) ([]string, error)
|
||||
```
|
||||
|
||||
Scheduler (in `main.go`):
|
||||
```go
|
||||
func startBackupScheduler(ctx context.Context, cfg *config.Config, dbPath, configPath string)
|
||||
```
|
||||
|
||||
### Config struct
|
||||
|
||||
```go
|
||||
type BackupConfig struct {
|
||||
Time string `yaml:"time"`
|
||||
}
|
||||
// Default: "00:00"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `backup.time` is in **local time** without timezone offset parsing
|
||||
- `.period.json` is the marker that prevents duplicate backups within the same period
|
||||
- Archive filenames contain only the date; uniqueness is ensured by per-period directories + the period marker
|
||||
- When changing naming or retention: update both the filename logic and the prune logic together
|
||||
|
||||
---
|
||||
|
||||
## Full Listing: `internal/appstate/backup.go`
|
||||
|
||||
```go
|
||||
package appstate
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type backupPeriod struct {
|
||||
name string
|
||||
retention int
|
||||
key func(time.Time) string
|
||||
date func(time.Time) string
|
||||
}
|
||||
|
||||
var backupPeriods = []backupPeriod{
|
||||
{
|
||||
name: "daily",
|
||||
retention: 7,
|
||||
key: func(t time.Time) string { return t.Format("2006-01-02") },
|
||||
date: func(t time.Time) string { return t.Format("2006-01-02") },
|
||||
},
|
||||
{
|
||||
name: "weekly",
|
||||
retention: 4,
|
||||
key: func(t time.Time) string {
|
||||
y, w := t.ISOWeek()
|
||||
return fmt.Sprintf("%04d-W%02d", y, w)
|
||||
},
|
||||
date: func(t time.Time) string { return t.Format("2006-01-02") },
|
||||
},
|
||||
{
|
||||
name: "monthly",
|
||||
retention: 12,
|
||||
key: func(t time.Time) string { return t.Format("2006-01") },
|
||||
date: func(t time.Time) string { return t.Format("2006-01-02") },
|
||||
},
|
||||
{
|
||||
name: "yearly",
|
||||
retention: 10,
|
||||
key: func(t time.Time) string { return t.Format("2006") },
|
||||
date: func(t time.Time) string { return t.Format("2006-01-02") },
|
||||
},
|
||||
}
|
||||
|
||||
func EnsureRotatingLocalBackup(dbPath, configPath string) ([]string, error) {
|
||||
if isBackupDisabled() || dbPath == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
root := resolveBackupRoot(dbPath)
|
||||
now := time.Now()
|
||||
created := make([]string, 0)
|
||||
for _, period := range backupPeriods {
|
||||
newFiles, err := ensurePeriodBackup(root, period, now, dbPath, configPath)
|
||||
if err != nil {
|
||||
return created, err
|
||||
}
|
||||
created = append(created, newFiles...)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Full Listing: Scheduler Hook (`main.go`)
|
||||
|
||||
```go
|
||||
func startBackupScheduler(ctx context.Context, cfg *config.Config, dbPath, configPath string) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
hour, minute, err := parseBackupTime(cfg.Backup.Time)
|
||||
if err != nil {
|
||||
slog.Warn("invalid backup time; using 00:00", "value", cfg.Backup.Time, "error", err)
|
||||
hour, minute = 0, 0
|
||||
}
|
||||
|
||||
// Startup check: create backup immediately if none exists for current periods
|
||||
if created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath); backupErr != nil {
|
||||
slog.Error("local backup failed", "error", backupErr)
|
||||
} else {
|
||||
for _, path := range created {
|
||||
slog.Info("local backup completed", "archive", path)
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
next := nextBackupTime(time.Now(), hour, minute)
|
||||
timer := time.NewTimer(time.Until(next))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-timer.C:
|
||||
start := time.Now()
|
||||
created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath)
|
||||
duration := time.Since(start)
|
||||
if backupErr != nil {
|
||||
slog.Error("local backup failed", "error", backupErr, "duration", duration)
|
||||
} else {
|
||||
for _, path := range created {
|
||||
slog.Info("local backup completed", "archive", path, "duration", duration)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseBackupTime(value string) (int, int, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return 0, 0, fmt.Errorf("empty backup time")
|
||||
}
|
||||
parsed, err := time.Parse("15:04", value)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return parsed.Hour(), parsed.Minute(), nil
|
||||
}
|
||||
|
||||
func nextBackupTime(now time.Time, hour, minute int) time.Time {
|
||||
target := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location())
|
||||
if !now.Before(target) {
|
||||
target = target.Add(24 * time.Hour)
|
||||
}
|
||||
return target
|
||||
}
|
||||
```
|
||||
136
bible/07-dev.md
Normal file
136
bible/07-dev.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# 07 — Development
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Run (dev)
|
||||
go run ./cmd/qfs
|
||||
make run
|
||||
|
||||
# Build
|
||||
make build-release # Optimized build with version info
|
||||
CGO_ENABLED=0 go build -o bin/qfs ./cmd/qfs
|
||||
|
||||
# Cross-platform build
|
||||
make build-all # Linux, macOS, Windows
|
||||
make build-windows # Windows only
|
||||
|
||||
# Verification
|
||||
go build ./cmd/qfs # Must compile without errors
|
||||
go vet ./... # Linter
|
||||
|
||||
# Tests
|
||||
go test ./...
|
||||
make test
|
||||
|
||||
# Utilities
|
||||
make install-hooks # Git hooks (block committing secrets)
|
||||
make clean # Clean bin/
|
||||
make help # All available commands
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Style
|
||||
|
||||
- **Formatting:** `gofmt` (mandatory)
|
||||
- **Logging:** `slog` only (structured logging)
|
||||
- **Errors:** explicit wrapping with context (`fmt.Errorf("context: %w", err)`)
|
||||
- **Style:** no unnecessary abstractions; minimum code for the task
|
||||
|
||||
---
|
||||
|
||||
## Guardrails
|
||||
|
||||
### What Must Never Be Restored
|
||||
|
||||
The following components were **intentionally removed** and must not be brought back:
|
||||
- cron jobs
|
||||
- importer utility
|
||||
- admin pricing UI/API
|
||||
- alerts
|
||||
- stock import
|
||||
|
||||
### Configuration Files
|
||||
|
||||
- `config.yaml` — runtime user file, **not stored in the repository**
|
||||
- `config.example.yaml` — the only config template in the repo
|
||||
|
||||
### Sync and Local-First
|
||||
|
||||
- Any sync changes must preserve local-first behavior
|
||||
- Local CRUD must not be blocked when MariaDB is unavailable
|
||||
|
||||
### Formats and UI
|
||||
|
||||
- **CSV export:** filename must use **project code** (`project.Code`), not project name
|
||||
Format: `YYYY-MM-DD (ProjectCode) ConfigName Article.csv`
|
||||
- **Breadcrumbs UI:** names longer than 16 characters must be truncated with an ellipsis
|
||||
|
||||
### Architecture Documentation
|
||||
|
||||
- **Every architectural decision must be recorded in `bible/`**
|
||||
- The corresponding Bible file must be updated **in the same commit** as the code change
|
||||
- On every user-requested commit, review and update the Bible in that same commit
|
||||
|
||||
---
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Add a Field to Configuration
|
||||
|
||||
1. Add the field to `LocalConfiguration` struct (`internal/models/`)
|
||||
2. Add GORM tags for the DB column
|
||||
3. Write a SQL migration (`migrations/`)
|
||||
4. Update `ConfigurationToLocal` / `LocalToConfiguration` converters
|
||||
5. Update API handlers and services
|
||||
|
||||
### Add a Field to Component
|
||||
|
||||
1. Add the field to `LocalComponent` struct (`internal/models/`)
|
||||
2. Update the SQL query in `SyncComponents()`
|
||||
3. Update the `componentRow` struct to match
|
||||
4. Update converter functions
|
||||
|
||||
### Add a Pricelist Price Lookup
|
||||
|
||||
```go
|
||||
// Modern pattern
|
||||
price, found := s.lookupPriceByPricelistID(pricelistID, lotName)
|
||||
if found && price > 0 {
|
||||
// use price
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Known Gotchas
|
||||
|
||||
1. **`CurrentPrice` removed from components** — any code using it will fail to compile
|
||||
2. **`HasPrice` filter removed** — `component.go ListComponents` no longer supports this filter
|
||||
3. **Quote calculation:** always offline-first (SQLite); online path is separate
|
||||
4. **Items JSON:** prices are stored in the `items` field of the configuration, not fetched from components
|
||||
5. **Migrations are additive:** already-applied migrations are skipped (checked by `id` in `local_schema_migrations`)
|
||||
6. **`SyncedAt` removed:** last component sync time is now in `app_settings` (key=`last_component_sync`)
|
||||
|
||||
---
|
||||
|
||||
## Debugging Price Issues
|
||||
|
||||
**Problem: quote returns no prices**
|
||||
1. Check that `pricelist_id` is set on the configuration
|
||||
2. Check that pricelist items exist: `SELECT COUNT(*) FROM local_pricelist_items`
|
||||
3. Check `lookupPriceByPricelistID()` in `quote.go`
|
||||
4. Verify the correct source is used (estimate/warehouse/competitor)
|
||||
|
||||
**Problem: component sync not working**
|
||||
1. Components sync as metadata only — no prices
|
||||
2. Prices come via a separate pricelist sync
|
||||
3. Check `SyncComponents()` and the MariaDB query
|
||||
|
||||
**Problem: configuration refresh does not update prices**
|
||||
1. Refresh uses the latest estimate pricelist by default
|
||||
2. Latest resolution ignores pricelists without items (`EXISTS local_pricelist_items`)
|
||||
3. Old prices in `config.items` are preserved if a line item is not found in the pricelist
|
||||
4. To force a pricelist update: set `configuration.pricelist_id`
|
||||
5. In configurator, `Авто` must remain auto-mode (runtime resolved ID must not be persisted as explicit selection)
|
||||
55
bible/README.md
Normal file
55
bible/README.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# QuoteForge Bible — Architectural Documentation
|
||||
|
||||
The single source of truth for architecture, schemas, and patterns.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
| File | Topic |
|
||||
|------|-------|
|
||||
| [01-overview.md](01-overview.md) | Product: purpose, features, tech stack, repository structure |
|
||||
| [02-architecture.md](02-architecture.md) | Architecture: local-first, sync, pricing, versioning |
|
||||
| [03-database.md](03-database.md) | DB schemas: SQLite + MariaDB, permissions, indexes |
|
||||
| [04-api.md](04-api.md) | API endpoints and web routes |
|
||||
| [05-config.md](05-config.md) | Configuration, environment variables, paths, installation |
|
||||
| [06-backup.md](06-backup.md) | Backup: implementation, rotation policy |
|
||||
| [07-dev.md](07-dev.md) | Development: commands, code style, guardrails |
|
||||
|
||||
---
|
||||
|
||||
## Bible Rules
|
||||
|
||||
> **Every architectural decision must be recorded in the Bible.**
|
||||
>
|
||||
> Any change to DB schema, data access patterns, sync behavior, API contracts,
|
||||
> configuration format, or any other system-level aspect — the corresponding `bible/` file
|
||||
> **must be updated in the same commit** as the code.
|
||||
>
|
||||
> On every user-requested commit, the Bible must be reviewed and updated in that commit.
|
||||
>
|
||||
> The Bible is the single source of truth for architecture. Outdated documentation is worse than none.
|
||||
|
||||
> **Documentation language: English.**
|
||||
>
|
||||
> All files in `bible/` are written and updated **in English only**.
|
||||
> Mixing languages is not allowed.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
**Where is user data stored?**
|
||||
SQLite → `~/Library/Application Support/QuoteForge/qfs.db` (macOS). MariaDB is sync-only.
|
||||
|
||||
**How to look up a price for a line item?**
|
||||
`local_pricelist_items` → by `pricelist_id` from config + `lot_name`. Prices are **never** taken from `local_components`.
|
||||
|
||||
**Pre-commit check?**
|
||||
`go build ./cmd/qfs && go vet ./...`
|
||||
|
||||
**What must never be restored?**
|
||||
cron jobs, admin pricing, alerts, stock import, importer utility — all removed intentionally.
|
||||
|
||||
**Where is the release changelog?**
|
||||
`releases/memory/v{major}.{minor}.{patch}.md`
|
||||
@@ -7,7 +7,6 @@ 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"
|
||||
@@ -16,7 +15,6 @@ 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)
|
||||
@@ -28,22 +26,6 @@ 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)
|
||||
@@ -51,6 +33,28 @@ 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
|
||||
@@ -149,23 +153,7 @@ func main() {
|
||||
log.Printf(" Skipped: %d", skipped)
|
||||
log.Printf(" Errors: %d", errors)
|
||||
|
||||
// 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")
|
||||
fmt.Println("\nDone! You can now run the server with: go run ./cmd/qfs")
|
||||
}
|
||||
|
||||
func derefUint(v *uint) uint {
|
||||
|
||||
@@ -10,7 +10,8 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appstate"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/driver/mysql"
|
||||
@@ -38,17 +39,29 @@ type migrationAction struct {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
localDBPath := flag.String("localdb", defaultLocalDBPath, "path to local SQLite database (default: user state dir or QFS_DB_PATH)")
|
||||
apply := flag.Bool("apply", false, "apply migration (default is preview only)")
|
||||
yes := flag.Bool("yes", false, "skip interactive confirmation (works only with -apply)")
|
||||
flag.Parse()
|
||||
|
||||
cfg, err := config.Load(*configPath)
|
||||
local, err := localdb.New(*localDBPath)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to load config: %v", err)
|
||||
log.Fatalf("failed to initialize local database: %v", err)
|
||||
}
|
||||
if !local.HasSettings() {
|
||||
log.Fatalf("SQLite connection settings are not configured. Run qfs setup first.")
|
||||
}
|
||||
dsn, err := local.GetDSN()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to build DSN from SQLite settings: %v", err)
|
||||
}
|
||||
dbUser := strings.TrimSpace(local.GetDBUser())
|
||||
|
||||
db, err := gorm.Open(mysql.Open(cfg.Database.DSN()), &gorm.Config{
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -59,7 +72,7 @@ func main() {
|
||||
log.Fatalf("precheck failed: %v", err)
|
||||
}
|
||||
|
||||
actions, existingProjects, err := buildPlan(db, cfg.Database.User)
|
||||
actions, existingProjects, err := buildPlan(db, dbUser)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to build migration plan: %v", err)
|
||||
}
|
||||
@@ -150,7 +163,7 @@ func buildPlan(db *gorm.DB, fallbackOwner string) ([]migrationAction, map[string
|
||||
}
|
||||
for i := range projects {
|
||||
p := projects[i]
|
||||
existingProjects[projectKey(p.OwnerUsername, p.Name)] = &p
|
||||
existingProjects[projectKey(p.OwnerUsername, derefString(p.Name))] = &p
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,12 +253,13 @@ func executePlan(db *gorm.DB, actions []migrationAction, existingProjects map[st
|
||||
|
||||
for _, action := range actions {
|
||||
key := projectKey(action.OwnerUsername, action.TargetProjectName)
|
||||
project := projectCache[key]
|
||||
project := projectCache[key]
|
||||
if project == nil {
|
||||
project = &models.Project{
|
||||
UUID: uuid.NewString(),
|
||||
OwnerUsername: action.OwnerUsername,
|
||||
Name: action.TargetProjectName,
|
||||
Code: action.TargetProjectName,
|
||||
Name: ptrString(action.TargetProjectName),
|
||||
IsActive: true,
|
||||
IsSystem: false,
|
||||
}
|
||||
@@ -255,7 +269,7 @@ func executePlan(db *gorm.DB, actions []migrationAction, existingProjects map[st
|
||||
projectCache[key] = project
|
||||
} else if !project.IsActive {
|
||||
if err := tx.Model(&models.Project{}).Where("uuid = ?", project.UUID).Update("is_active", true).Error; err != nil {
|
||||
return fmt.Errorf("reactivate project %s (%s): %w", project.Name, project.UUID, err)
|
||||
return fmt.Errorf("reactivate project %s (%s): %w", derefString(project.Name), project.UUID, err)
|
||||
}
|
||||
project.IsActive = true
|
||||
}
|
||||
@@ -281,3 +295,14 @@ func setKeys(set map[string]struct{}) []string {
|
||||
func projectKey(owner, name string) string {
|
||||
return owner + "||" + name
|
||||
}
|
||||
|
||||
func derefString(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func ptrString(value string) *string {
|
||||
return &value
|
||||
}
|
||||
|
||||
66
cmd/qfs/config_migration_test.go
Normal file
66
cmd/qfs/config_migration_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||
)
|
||||
|
||||
func TestMigrateConfigFileToRuntimeShapeDropsDeprecatedSections(t *testing.T) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
|
||||
legacy := `server:
|
||||
host: "0.0.0.0"
|
||||
port: 9191
|
||||
database:
|
||||
host: "legacy-db"
|
||||
port: 3306
|
||||
name: "RFQ_LOG"
|
||||
user: "old"
|
||||
password: "REDACTED_TEST_PASSWORD"
|
||||
pricing:
|
||||
default_method: "median"
|
||||
logging:
|
||||
level: "debug"
|
||||
format: "text"
|
||||
output: "stdout"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(legacy), 0644); err != nil {
|
||||
t.Fatalf("write legacy config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load legacy config: %v", err)
|
||||
}
|
||||
setConfigDefaults(cfg)
|
||||
if err := migrateConfigFileToRuntimeShape(path, cfg); err != nil {
|
||||
t.Fatalf("migrate config: %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read migrated config: %v", err)
|
||||
}
|
||||
text := string(got)
|
||||
if strings.Contains(text, "database:") {
|
||||
t.Fatalf("migrated config still contains deprecated database section:\n%s", text)
|
||||
}
|
||||
if strings.Contains(text, "pricing:") {
|
||||
t.Fatalf("migrated config still contains deprecated pricing section:\n%s", text)
|
||||
}
|
||||
if !strings.Contains(text, "server:") || !strings.Contains(text, "logging:") {
|
||||
t.Fatalf("migrated config missing required sections:\n%s", text)
|
||||
}
|
||||
if !strings.Contains(text, "port: 9191") {
|
||||
t.Fatalf("migrated config did not preserve server port:\n%s", text)
|
||||
}
|
||||
if !strings.Contains(text, "level: debug") {
|
||||
t.Fatalf("migrated config did not preserve logging level:\n%s", text)
|
||||
}
|
||||
}
|
||||
481
cmd/qfs/main.go
481
cmd/qfs/main.go
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
@@ -34,6 +35,7 @@ import (
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
"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,10 +46,14 @@ var Version = "dev"
|
||||
|
||||
const backgroundSyncInterval = 5 * time.Minute
|
||||
const onDemandPullCooldown = 30 * time.Second
|
||||
const startupConsoleWarning = "Не закрывайте консоль иначе приложение не будет работать"
|
||||
|
||||
func main() {
|
||||
showStartupConsoleWarning()
|
||||
|
||||
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)")
|
||||
resetLocalDB := flag.Bool("reset-localdb", false, "reset local SQLite data on startup (keeps connection settings)")
|
||||
migrate := flag.Bool("migrate", false, "run database migrations")
|
||||
version := flag.Bool("version", false, "show version information")
|
||||
flag.Parse()
|
||||
@@ -62,18 +68,18 @@ func main() {
|
||||
slog.Info("starting qfs", "version", Version, "executable", exePath)
|
||||
appmeta.SetVersion(Version)
|
||||
|
||||
resolvedConfigPath, err := appstate.ResolveConfigPath(*configPath)
|
||||
if err != nil {
|
||||
slog.Error("failed to resolve config path", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
resolvedLocalDBPath, err := appstate.ResolveDBPath(*localDBPath)
|
||||
if err != nil {
|
||||
slog.Error("failed to resolve local database path", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
resolvedConfigPath, err := appstate.ResolveConfigPathNearDB(*configPath, resolvedLocalDBPath)
|
||||
if err != nil {
|
||||
slog.Error("failed to resolve config path", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Migrate legacy project-local config path to the user state directory when using defaults.
|
||||
if *configPath == "" && os.Getenv("QFS_CONFIG_PATH") == "" {
|
||||
migratedFrom, migrateErr := appstate.MigrateLegacyFile(resolvedConfigPath, []string{"config.yaml"})
|
||||
@@ -98,6 +104,13 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
if shouldResetLocalDB(*resetLocalDB) {
|
||||
if err := localdb.ResetData(resolvedLocalDBPath); err != nil {
|
||||
slog.Error("failed to reset local database", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize local SQLite database (always used)
|
||||
local, err := localdb.New(resolvedLocalDBPath)
|
||||
if err != nil {
|
||||
@@ -113,6 +126,10 @@ 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) {
|
||||
@@ -125,6 +142,10 @@ 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)
|
||||
@@ -222,6 +243,10 @@ func main() {
|
||||
syncWorker := sync.NewWorker(syncService, connMgr, backgroundSyncInterval)
|
||||
go syncWorker.Start(workerCtx)
|
||||
|
||||
backupCtx, backupCancel := context.WithCancel(context.Background())
|
||||
defer backupCancel()
|
||||
go startBackupScheduler(backupCtx, cfg, resolvedLocalDBPath, resolvedConfigPath)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Address(),
|
||||
Handler: router,
|
||||
@@ -264,6 +289,7 @@ func main() {
|
||||
// Stop background sync worker first
|
||||
syncWorker.Stop()
|
||||
workerCancel()
|
||||
backupCancel()
|
||||
|
||||
// Then shutdown HTTP server
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
@@ -280,6 +306,36 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func showStartupConsoleWarning() {
|
||||
// Visible in console output.
|
||||
fmt.Println(startupConsoleWarning)
|
||||
// Keep the warning always visible in the console window title when supported.
|
||||
fmt.Printf("\033]0;%s\007", startupConsoleWarning)
|
||||
}
|
||||
|
||||
func shouldResetLocalDB(flagValue bool) bool {
|
||||
if flagValue {
|
||||
return true
|
||||
}
|
||||
value := strings.TrimSpace(os.Getenv("QFS_RESET_LOCAL_DB"))
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(value) {
|
||||
case "1", "true", "yes", "y":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func derefString(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func setConfigDefaults(cfg *config.Config) {
|
||||
if cfg.Server.Host == "" {
|
||||
cfg.Server.Host = "127.0.0.1"
|
||||
@@ -314,6 +370,173 @@ func setConfigDefaults(cfg *config.Config) {
|
||||
if cfg.Pricing.MinQuotesForMedian == 0 {
|
||||
cfg.Pricing.MinQuotesForMedian = 3
|
||||
}
|
||||
if cfg.Backup.Time == "" {
|
||||
cfg.Backup.Time = "00:00"
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
backup:
|
||||
time: "00:00"
|
||||
|
||||
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 runtimeBackupConfig struct {
|
||||
Time string `yaml:"time"`
|
||||
}
|
||||
|
||||
type runtimeConfigFile struct {
|
||||
Server runtimeServerConfig `yaml:"server"`
|
||||
Logging runtimeLoggingConfig `yaml:"logging"`
|
||||
Backup runtimeBackupConfig `yaml:"backup"`
|
||||
}
|
||||
|
||||
// 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,
|
||||
},
|
||||
Backup: runtimeBackupConfig{
|
||||
Time: cfg.Backup.Time,
|
||||
},
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func startBackupScheduler(ctx context.Context, cfg *config.Config, dbPath, configPath string) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
|
||||
hour, minute, err := parseBackupTime(cfg.Backup.Time)
|
||||
if err != nil {
|
||||
slog.Warn("invalid backup time; using 00:00", "value", cfg.Backup.Time, "error", err)
|
||||
hour = 0
|
||||
minute = 0
|
||||
}
|
||||
|
||||
if created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath); backupErr != nil {
|
||||
slog.Error("local backup failed", "error", backupErr)
|
||||
} else if len(created) > 0 {
|
||||
for _, path := range created {
|
||||
slog.Info("local backup completed", "archive", path)
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
next := nextBackupTime(time.Now(), hour, minute)
|
||||
timer := time.NewTimer(time.Until(next))
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-timer.C:
|
||||
start := time.Now()
|
||||
created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath)
|
||||
duration := time.Since(start)
|
||||
if backupErr != nil {
|
||||
slog.Error("local backup failed", "error", backupErr, "duration", duration)
|
||||
} else {
|
||||
for _, path := range created {
|
||||
slog.Info("local backup completed", "archive", path, "duration", duration)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseBackupTime(value string) (int, int, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return 0, 0, fmt.Errorf("empty backup time")
|
||||
}
|
||||
parsed, err := time.Parse("15:04", value)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return parsed.Hour(), parsed.Minute(), nil
|
||||
}
|
||||
|
||||
func nextBackupTime(now time.Time, hour, minute int) time.Time {
|
||||
location := now.Location()
|
||||
target := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, location)
|
||||
if !now.Before(target) {
|
||||
target = target.Add(24 * time.Hour)
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
// runSetupMode starts a minimal server that only serves the setup page
|
||||
@@ -332,11 +555,11 @@ func runSetupMode(local *localdb.LocalDB) {
|
||||
router := gin.New()
|
||||
router.Use(gin.Recovery())
|
||||
|
||||
staticPath := filepath.Join("web", "static")
|
||||
if stat, err := os.Stat(staticPath); err == nil && stat.IsDir() {
|
||||
router.Static("/static", staticPath)
|
||||
} else if staticFS, err := qfassets.StaticFS(); err == nil {
|
||||
if staticFS, err := qfassets.StaticFS(); err == nil {
|
||||
router.StaticFS("/static", http.FS(staticFS))
|
||||
} else {
|
||||
slog.Error("failed to load embedded static assets", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Setup routes only
|
||||
@@ -481,12 +704,12 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
if mariaDB != nil {
|
||||
componentService = services.NewComponentService(componentRepo, categoryRepo, statsRepo)
|
||||
quoteService = services.NewQuoteService(componentRepo, statsRepo, pricelistRepo, local, nil)
|
||||
exportService = services.NewExportService(cfg.Export, categoryRepo)
|
||||
exportService = services.NewExportService(cfg.Export, categoryRepo, local)
|
||||
} else {
|
||||
// In offline mode, we still need to create services that don't require DB.
|
||||
componentService = services.NewComponentService(nil, nil, nil)
|
||||
quoteService = services.NewQuoteService(nil, nil, nil, local, nil)
|
||||
exportService = services.NewExportService(cfg.Export, nil)
|
||||
exportService = services.NewExportService(cfg.Export, nil, local)
|
||||
}
|
||||
|
||||
// isOnline function for local-first architecture
|
||||
@@ -595,7 +818,7 @@ 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)
|
||||
exportHandler := handlers.NewExportHandler(exportService, configService, projectService)
|
||||
pricelistHandler := handlers.NewPricelistHandler(local)
|
||||
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval)
|
||||
if err != nil {
|
||||
@@ -622,11 +845,10 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
router.Use(middleware.OfflineDetector(connMgr, local))
|
||||
|
||||
// Static files (use filepath.Join for Windows compatibility)
|
||||
staticPath := filepath.Join("web", "static")
|
||||
if stat, err := os.Stat(staticPath); err == nil && stat.IsDir() {
|
||||
router.Static("/static", staticPath)
|
||||
} else if staticFS, err := qfassets.StaticFS(); err == nil {
|
||||
if staticFS, err := qfassets.StaticFS(); err == nil {
|
||||
router.StaticFS("/static", http.FS(staticFS))
|
||||
} else {
|
||||
return nil, nil, fmt.Errorf("load embedded static assets: %w", err)
|
||||
}
|
||||
|
||||
// Health check
|
||||
@@ -715,6 +937,7 @@ 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("/configs/:uuid/revisions", webHandler.ConfigRevisions)
|
||||
router.GET("/pricelists", webHandler.Pricelists)
|
||||
router.GET("/pricelists/:id", webHandler.PricelistDetail)
|
||||
|
||||
@@ -826,6 +1049,23 @@ 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)
|
||||
@@ -846,7 +1086,16 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
|
||||
config, err := configService.UpdateNoAuth(uuid, &req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
switch {
|
||||
case errors.Is(err, services.ErrConfigNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, services.ErrProjectNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, services.ErrProjectForbidden):
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -897,15 +1146,20 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
configs.POST("/:uuid/clone", func(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Name string `json:"name"`
|
||||
FromVersion int `json:"from_version"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
config, err := configService.CloneNoAuth(uuid, req.Name, dbUsername)
|
||||
config, err := configService.CloneNoAuthToProjectFromVersion(uuid, req.Name, dbUsername, nil, req.FromVersion)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrConfigVersionNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -1052,6 +1306,25 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
"current_version": currentVersion,
|
||||
})
|
||||
})
|
||||
|
||||
configs.GET("/:uuid/export", exportHandler.ExportConfigCSV)
|
||||
|
||||
configs.PATCH("/:uuid/server-count", func(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
var req struct {
|
||||
ServerCount int `json:"server_count" binding:"required,min=1"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
config, err := configService.UpdateServerCount(uuid, req.ServerCount)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, config)
|
||||
})
|
||||
}
|
||||
|
||||
projects := api.Group("/projects")
|
||||
@@ -1064,7 +1337,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
search := strings.ToLower(strings.TrimSpace(c.Query("search")))
|
||||
author := strings.ToLower(strings.TrimSpace(c.Query("author")))
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "10"))
|
||||
// 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" {
|
||||
@@ -1104,7 +1378,10 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
if status == "archived" && p.IsActive {
|
||||
continue
|
||||
}
|
||||
if search != "" && !strings.Contains(strings.ToLower(p.Name), search) {
|
||||
if search != "" &&
|
||||
!strings.Contains(strings.ToLower(derefString(p.Name)), search) &&
|
||||
!strings.Contains(strings.ToLower(p.Code), search) &&
|
||||
!strings.Contains(strings.ToLower(p.Variant), search) {
|
||||
continue
|
||||
}
|
||||
if author != "" && !strings.Contains(strings.ToLower(strings.TrimSpace(p.OwnerUsername)), author) {
|
||||
@@ -1117,8 +1394,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
left := filtered[i]
|
||||
right := filtered[j]
|
||||
if sortField == "name" {
|
||||
leftName := strings.ToLower(strings.TrimSpace(left.Name))
|
||||
rightName := strings.ToLower(strings.TrimSpace(right.Name))
|
||||
leftName := strings.ToLower(strings.TrimSpace(derefString(left.Name)))
|
||||
rightName := strings.ToLower(strings.TrimSpace(derefString(right.Name)))
|
||||
if leftName == rightName {
|
||||
if sortDir == "asc" {
|
||||
return left.CreatedAt.Before(right.CreatedAt)
|
||||
@@ -1131,8 +1408,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
return leftName > rightName
|
||||
}
|
||||
if left.CreatedAt.Equal(right.CreatedAt) {
|
||||
leftName := strings.ToLower(strings.TrimSpace(left.Name))
|
||||
rightName := strings.ToLower(strings.TrimSpace(right.Name))
|
||||
leftName := strings.ToLower(strings.TrimSpace(derefString(left.Name)))
|
||||
rightName := strings.ToLower(strings.TrimSpace(derefString(right.Name)))
|
||||
if sortDir == "asc" {
|
||||
return leftName < rightName
|
||||
}
|
||||
@@ -1191,6 +1468,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
"id": p.ID,
|
||||
"uuid": p.UUID,
|
||||
"owner_username": p.OwnerUsername,
|
||||
"code": p.Code,
|
||||
"variant": p.Variant,
|
||||
"name": p.Name,
|
||||
"tracker_url": p.TrackerURL,
|
||||
"is_active": p.IsActive,
|
||||
@@ -1216,19 +1495,55 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
})
|
||||
})
|
||||
|
||||
// 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"`
|
||||
Code string `json:"code"`
|
||||
Variant string `json:"variant"`
|
||||
Name string `json:"name"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
simplified := make([]ProjectSimple, 0, len(allProjects))
|
||||
for _, p := range allProjects {
|
||||
simplified = append(simplified, ProjectSimple{
|
||||
UUID: p.UUID,
|
||||
Code: p.Code,
|
||||
Variant: p.Variant,
|
||||
Name: derefString(p.Name),
|
||||
IsActive: p.IsActive,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, simplified)
|
||||
})
|
||||
|
||||
projects.POST("", func(c *gin.Context) {
|
||||
var req services.CreateProjectRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "project name is required"})
|
||||
if strings.TrimSpace(req.Code) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "project code is required"})
|
||||
return
|
||||
}
|
||||
project, err := projectService.Create(dbUsername, &req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
switch {
|
||||
case errors.Is(err, services.ErrProjectCodeExists):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, project)
|
||||
@@ -1256,13 +1571,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "project name is required"})
|
||||
return
|
||||
}
|
||||
project, err := projectService.Update(c.Param("uuid"), dbUsername, &req)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrProjectCodeExists):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, services.ErrProjectNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, services.ErrProjectForbidden):
|
||||
@@ -1305,6 +1618,23 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
c.JSON(http.StatusOK, gin.H{"message": "project reactivated"})
|
||||
})
|
||||
|
||||
projects.DELETE("/:uuid", func(c *gin.Context) {
|
||||
if err := projectService.DeleteVariant(c.Param("uuid"), dbUsername); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrCannotDeleteMainVariant):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, services.ErrProjectNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, services.ErrProjectForbidden):
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "variant deleted"})
|
||||
})
|
||||
|
||||
projects.GET("/:uuid/configs", func(c *gin.Context) {
|
||||
triggerPull("configs", &configsPullState, syncConfigurationsFromServer)
|
||||
|
||||
@@ -1330,6 +1660,43 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
c.JSON(http.StatusOK, result)
|
||||
})
|
||||
|
||||
projects.PATCH("/:uuid/configs/reorder", func(c *gin.Context) {
|
||||
var req struct {
|
||||
OrderedUUIDs []string `json:"ordered_uuids"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(req.OrderedUUIDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ordered_uuids is required"})
|
||||
return
|
||||
}
|
||||
|
||||
configs, err := configService.ReorderProjectConfigurationsNoAuth(c.Param("uuid"), req.OrderedUUIDs)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrProjectNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
total := 0.0
|
||||
for i := range configs {
|
||||
if configs[i].TotalPrice != nil {
|
||||
total += *configs[i].TotalPrice
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"project_uuid": c.Param("uuid"),
|
||||
"configurations": configs,
|
||||
"total": total,
|
||||
})
|
||||
})
|
||||
|
||||
projects.POST("/:uuid/configs", func(c *gin.Context) {
|
||||
var req services.CreateConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -1347,6 +1714,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
c.JSON(http.StatusCreated, config)
|
||||
})
|
||||
|
||||
projects.GET("/:uuid/export", exportHandler.ExportProjectCSV)
|
||||
|
||||
projects.POST("/:uuid/configs/:config_uuid/clone", func(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
@@ -1379,6 +1748,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
syncAPI.POST("/push", syncHandler.PushPendingChanges)
|
||||
syncAPI.GET("/pending/count", syncHandler.GetPendingCount)
|
||||
syncAPI.GET("/pending", syncHandler.GetPendingChanges)
|
||||
syncAPI.POST("/repair", syncHandler.RepairPendingChanges)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1430,11 +1800,37 @@ func requestLogger() gin.HandlerFunc {
|
||||
path := c.Request.URL.Path
|
||||
query := c.Request.URL.RawQuery
|
||||
|
||||
blw := &captureResponseWriter{
|
||||
ResponseWriter: c.Writer,
|
||||
body: bytes.NewBuffer(nil),
|
||||
}
|
||||
c.Writer = blw
|
||||
|
||||
c.Next()
|
||||
|
||||
latency := time.Since(start)
|
||||
status := c.Writer.Status()
|
||||
|
||||
if status >= http.StatusBadRequest {
|
||||
responseBody := strings.TrimSpace(blw.body.String())
|
||||
if len(responseBody) > 2048 {
|
||||
responseBody = responseBody[:2048] + "...(truncated)"
|
||||
}
|
||||
errText := strings.TrimSpace(c.Errors.String())
|
||||
|
||||
slog.Error("request failed",
|
||||
"method", c.Request.Method,
|
||||
"path", path,
|
||||
"query", query,
|
||||
"status", status,
|
||||
"latency", latency,
|
||||
"ip", c.ClientIP(),
|
||||
"errors", errText,
|
||||
"response", responseBody,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("request",
|
||||
"method", c.Request.Method,
|
||||
"path", path,
|
||||
@@ -1445,3 +1841,22 @@ func requestLogger() gin.HandlerFunc {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type captureResponseWriter struct {
|
||||
gin.ResponseWriter
|
||||
body *bytes.Buffer
|
||||
}
|
||||
|
||||
func (w *captureResponseWriter) Write(b []byte) (int, error) {
|
||||
if len(b) > 0 {
|
||||
_, _ = w.body.Write(b)
|
||||
}
|
||||
return w.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
func (w *captureResponseWriter) WriteString(s string) (int, error) {
|
||||
if s != "" {
|
||||
_, _ = w.body.WriteString(s)
|
||||
}
|
||||
return w.ResponseWriter.WriteString(s)
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ func TestConfigurationVersioningAPI(t *testing.T) {
|
||||
if err := json.Unmarshal(rbRec.Body.Bytes(), &rbResp); err != nil {
|
||||
t.Fatalf("unmarshal rollback response: %v", err)
|
||||
}
|
||||
if rbResp.Message == "" || rbResp.CurrentVersion.VersionNo != 3 {
|
||||
if rbResp.Message == "" || rbResp.CurrentVersion.VersionNo != 2 {
|
||||
t.Fatalf("unexpected rollback response: %+v", rbResp)
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ func TestProjectArchiveHidesConfigsAndCloneIntoProject(t *testing.T) {
|
||||
t.Fatalf("setup router: %v", err)
|
||||
}
|
||||
|
||||
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"P1"}`)))
|
||||
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"P1","code":"P1"}`)))
|
||||
createProjectReq.Header.Set("Content-Type", "application/json")
|
||||
createProjectRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(createProjectRec, createProjectReq)
|
||||
@@ -243,7 +243,7 @@ func TestConfigMoveToProjectEndpoint(t *testing.T) {
|
||||
t.Fatalf("setup router: %v", err)
|
||||
}
|
||||
|
||||
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"Move Project"}`)))
|
||||
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"Move Project","code":"MOVE"}`)))
|
||||
createProjectReq.Header.Set("Content-Type", "application/json")
|
||||
createProjectRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(createProjectRec, createProjectReq)
|
||||
|
||||
@@ -37,6 +37,9 @@ export:
|
||||
max_file_age: "1h"
|
||||
company_name: "Your Company Name"
|
||||
|
||||
backup:
|
||||
time: "00:00"
|
||||
|
||||
alerts:
|
||||
enabled: true
|
||||
check_interval: "1h"
|
||||
|
||||
15
crontab
15
crontab
@@ -1,15 +0,0 @@
|
||||
# Cron jobs for QuoteForge
|
||||
# Run alerts check every hour
|
||||
0 * * * * /app/quoteforge-cron -job=alerts
|
||||
|
||||
# Run price updates daily at 2 AM
|
||||
0 2 * * * /app/quoteforge-cron -job=update-prices
|
||||
|
||||
# Reset weekly counters every Sunday at 1 AM
|
||||
0 1 * * 0 /app/quoteforge-cron -job=reset-counters
|
||||
|
||||
# Update popularity scores daily at 3 AM
|
||||
0 3 * * * /app/quoteforge-cron -job=update-popularity
|
||||
|
||||
# Log rotation (optional)
|
||||
# 0 0 * * * /usr/bin/logrotate /etc/logrotate.conf
|
||||
BIN
dist/qfs-darwin-amd64
vendored
Executable file
BIN
dist/qfs-darwin-amd64
vendored
Executable file
Binary file not shown.
BIN
dist/qfs-darwin-arm64
vendored
Executable file
BIN
dist/qfs-darwin-arm64
vendored
Executable file
Binary file not shown.
BIN
dist/qfs-linux-amd64
vendored
Executable file
BIN
dist/qfs-linux-amd64
vendored
Executable file
Binary file not shown.
BIN
dist/qfs-windows-amd64.exe
vendored
Executable file
BIN
dist/qfs-windows-amd64.exe
vendored
Executable file
Binary file not shown.
273
internal/appstate/backup.go
Normal file
273
internal/appstate/backup.go
Normal file
@@ -0,0 +1,273 @@
|
||||
package appstate
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type backupPeriod struct {
|
||||
name string
|
||||
retention int
|
||||
key func(time.Time) string
|
||||
date func(time.Time) string
|
||||
}
|
||||
|
||||
var backupPeriods = []backupPeriod{
|
||||
{
|
||||
name: "daily",
|
||||
retention: 7,
|
||||
key: func(t time.Time) string {
|
||||
return t.Format("2006-01-02")
|
||||
},
|
||||
date: func(t time.Time) string {
|
||||
return t.Format("2006-01-02")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "weekly",
|
||||
retention: 4,
|
||||
key: func(t time.Time) string {
|
||||
y, w := t.ISOWeek()
|
||||
return fmt.Sprintf("%04d-W%02d", y, w)
|
||||
},
|
||||
date: func(t time.Time) string {
|
||||
return t.Format("2006-01-02")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "monthly",
|
||||
retention: 12,
|
||||
key: func(t time.Time) string {
|
||||
return t.Format("2006-01")
|
||||
},
|
||||
date: func(t time.Time) string {
|
||||
return t.Format("2006-01-02")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "yearly",
|
||||
retention: 10,
|
||||
key: func(t time.Time) string {
|
||||
return t.Format("2006")
|
||||
},
|
||||
date: func(t time.Time) string {
|
||||
return t.Format("2006-01-02")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const (
|
||||
envBackupDisable = "QFS_BACKUP_DISABLE"
|
||||
envBackupDir = "QFS_BACKUP_DIR"
|
||||
)
|
||||
|
||||
var backupNow = time.Now
|
||||
|
||||
// EnsureRotatingLocalBackup creates or refreshes daily/weekly/monthly/yearly backups
|
||||
// for the local database and config. It keeps a limited number per period.
|
||||
func EnsureRotatingLocalBackup(dbPath, configPath string) ([]string, error) {
|
||||
if isBackupDisabled() {
|
||||
return nil, nil
|
||||
}
|
||||
if dbPath == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("stat db: %w", err)
|
||||
}
|
||||
|
||||
root := resolveBackupRoot(dbPath)
|
||||
now := backupNow()
|
||||
|
||||
created := make([]string, 0)
|
||||
for _, period := range backupPeriods {
|
||||
newFiles, err := ensurePeriodBackup(root, period, now, dbPath, configPath)
|
||||
if err != nil {
|
||||
return created, err
|
||||
}
|
||||
if len(newFiles) > 0 {
|
||||
created = append(created, newFiles...)
|
||||
}
|
||||
}
|
||||
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func resolveBackupRoot(dbPath string) string {
|
||||
if fromEnv := strings.TrimSpace(os.Getenv(envBackupDir)); fromEnv != "" {
|
||||
return filepath.Clean(fromEnv)
|
||||
}
|
||||
return filepath.Join(filepath.Dir(dbPath), "backups")
|
||||
}
|
||||
|
||||
func isBackupDisabled() bool {
|
||||
val := strings.ToLower(strings.TrimSpace(os.Getenv(envBackupDisable)))
|
||||
return val == "1" || val == "true" || val == "yes"
|
||||
}
|
||||
|
||||
func ensurePeriodBackup(root string, period backupPeriod, now time.Time, dbPath, configPath string) ([]string, error) {
|
||||
key := period.key(now)
|
||||
periodDir := filepath.Join(root, period.name)
|
||||
if err := os.MkdirAll(periodDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("create %s backup dir: %w", period.name, err)
|
||||
}
|
||||
|
||||
if hasBackupForKey(periodDir, key) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
archiveName := fmt.Sprintf("qfs-backp-%s.zip", period.date(now))
|
||||
archivePath := filepath.Join(periodDir, archiveName)
|
||||
|
||||
if err := createBackupArchive(archivePath, dbPath, configPath); err != nil {
|
||||
return nil, fmt.Errorf("create %s backup archive: %w", period.name, err)
|
||||
}
|
||||
|
||||
if err := writePeriodMarker(periodDir, key); err != nil {
|
||||
return []string{archivePath}, err
|
||||
}
|
||||
|
||||
if err := pruneOldBackups(periodDir, period.retention); err != nil {
|
||||
return []string{archivePath}, err
|
||||
}
|
||||
|
||||
return []string{archivePath}, nil
|
||||
}
|
||||
|
||||
func hasBackupForKey(periodDir, key string) bool {
|
||||
marker := periodMarker{Key: ""}
|
||||
data, err := os.ReadFile(periodMarkerPath(periodDir))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if err := json.Unmarshal(data, &marker); err != nil {
|
||||
return false
|
||||
}
|
||||
return marker.Key == key
|
||||
}
|
||||
|
||||
type periodMarker struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
func periodMarkerPath(periodDir string) string {
|
||||
return filepath.Join(periodDir, ".period.json")
|
||||
}
|
||||
|
||||
func writePeriodMarker(periodDir, key string) error {
|
||||
data, err := json.MarshalIndent(periodMarker{Key: key}, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(periodMarkerPath(periodDir), data, 0644)
|
||||
}
|
||||
|
||||
func pruneOldBackups(periodDir string, keep int) error {
|
||||
entries, err := os.ReadDir(periodDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read backups dir: %w", err)
|
||||
}
|
||||
|
||||
files := make([]os.DirEntry, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(entry.Name(), ".zip") {
|
||||
files = append(files, entry)
|
||||
}
|
||||
}
|
||||
|
||||
if len(files) <= keep {
|
||||
return nil
|
||||
}
|
||||
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
infoI, errI := files[i].Info()
|
||||
infoJ, errJ := files[j].Info()
|
||||
if errI != nil || errJ != nil {
|
||||
return files[i].Name() < files[j].Name()
|
||||
}
|
||||
return infoI.ModTime().Before(infoJ.ModTime())
|
||||
})
|
||||
|
||||
for i := 0; i < len(files)-keep; i++ {
|
||||
path := filepath.Join(periodDir, files[i].Name())
|
||||
if err := os.Remove(path); err != nil {
|
||||
return fmt.Errorf("remove old backup %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createBackupArchive(destPath, dbPath, configPath string) error {
|
||||
file, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
zipWriter := zip.NewWriter(file)
|
||||
if err := addZipFile(zipWriter, dbPath); err != nil {
|
||||
_ = zipWriter.Close()
|
||||
return err
|
||||
}
|
||||
_ = addZipOptionalFile(zipWriter, dbPath+"-wal")
|
||||
_ = addZipOptionalFile(zipWriter, dbPath+"-shm")
|
||||
|
||||
if strings.TrimSpace(configPath) != "" {
|
||||
_ = addZipOptionalFile(zipWriter, configPath)
|
||||
}
|
||||
|
||||
if err := zipWriter.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return file.Sync()
|
||||
}
|
||||
|
||||
func addZipOptionalFile(writer *zip.Writer, path string) error {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return nil
|
||||
}
|
||||
return addZipFile(writer, path)
|
||||
}
|
||||
|
||||
func addZipFile(writer *zip.Writer, path string) error {
|
||||
in, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
info, err := in.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
header, err := zip.FileInfoHeader(info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Name = filepath.Base(path)
|
||||
header.Method = zip.Deflate
|
||||
|
||||
out, err := writer.CreateHeader(header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = io.Copy(out, in)
|
||||
return err
|
||||
}
|
||||
83
internal/appstate/backup_test.go
Normal file
83
internal/appstate/backup_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package appstate
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEnsureRotatingLocalBackupCreatesAndRotates(t *testing.T) {
|
||||
temp := t.TempDir()
|
||||
dbPath := filepath.Join(temp, "qfs.db")
|
||||
cfgPath := filepath.Join(temp, "config.yaml")
|
||||
|
||||
if err := os.WriteFile(dbPath, []byte("db"), 0644); err != nil {
|
||||
t.Fatalf("write db: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
prevNow := backupNow
|
||||
defer func() { backupNow = prevNow }()
|
||||
backupNow = func() time.Time { return time.Date(2026, 2, 11, 10, 0, 0, 0, time.UTC) }
|
||||
|
||||
created, err := EnsureRotatingLocalBackup(dbPath, cfgPath)
|
||||
if err != nil {
|
||||
t.Fatalf("backup: %v", err)
|
||||
}
|
||||
if len(created) == 0 {
|
||||
t.Fatalf("expected backup to be created")
|
||||
}
|
||||
|
||||
dailyArchive := filepath.Join(temp, "backups", "daily", "qfs-backp-2026-02-11.zip")
|
||||
if _, err := os.Stat(dailyArchive); err != nil {
|
||||
t.Fatalf("daily archive missing: %v", err)
|
||||
}
|
||||
|
||||
backupNow = func() time.Time { return time.Date(2026, 2, 12, 10, 0, 0, 0, time.UTC) }
|
||||
created, err = EnsureRotatingLocalBackup(dbPath, cfgPath)
|
||||
if err != nil {
|
||||
t.Fatalf("backup rotate: %v", err)
|
||||
}
|
||||
if len(created) == 0 {
|
||||
t.Fatalf("expected backup to be created for new day")
|
||||
}
|
||||
|
||||
dailyArchive = filepath.Join(temp, "backups", "daily", "qfs-backp-2026-02-12.zip")
|
||||
if _, err := os.Stat(dailyArchive); err != nil {
|
||||
t.Fatalf("daily archive missing after rotate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureRotatingLocalBackupEnvControls(t *testing.T) {
|
||||
temp := t.TempDir()
|
||||
dbPath := filepath.Join(temp, "qfs.db")
|
||||
cfgPath := filepath.Join(temp, "config.yaml")
|
||||
|
||||
if err := os.WriteFile(dbPath, []byte("db"), 0644); err != nil {
|
||||
t.Fatalf("write db: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
backupRoot := filepath.Join(temp, "custom_backups")
|
||||
t.Setenv(envBackupDir, backupRoot)
|
||||
|
||||
if _, err := EnsureRotatingLocalBackup(dbPath, cfgPath); err != nil {
|
||||
t.Fatalf("backup with env: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(backupRoot, "daily", "meta.json")); err != nil {
|
||||
t.Fatalf("expected backup in custom dir: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv(envBackupDisable, "1")
|
||||
if _, err := EnsureRotatingLocalBackup(dbPath, cfgPath); err != nil {
|
||||
t.Fatalf("backup disabled: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(backupRoot, "daily", "meta.json")); err != nil {
|
||||
t.Fatalf("backup should remain from previous run: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -55,6 +56,25 @@ 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.
|
||||
|
||||
124
internal/article/categories.go
Normal file
124
internal/article/categories.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package article
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
)
|
||||
|
||||
// ErrMissingCategoryForLot is returned when a lot has no category in local_pricelist_items.lot_category.
|
||||
var ErrMissingCategoryForLot = errors.New("missing_category_for_lot")
|
||||
|
||||
type MissingCategoryForLotError struct {
|
||||
LotName string
|
||||
}
|
||||
|
||||
func (e *MissingCategoryForLotError) Error() string {
|
||||
if e == nil || strings.TrimSpace(e.LotName) == "" {
|
||||
return ErrMissingCategoryForLot.Error()
|
||||
}
|
||||
return fmt.Sprintf("%s: %s", ErrMissingCategoryForLot.Error(), e.LotName)
|
||||
}
|
||||
|
||||
func (e *MissingCategoryForLotError) Unwrap() error {
|
||||
return ErrMissingCategoryForLot
|
||||
}
|
||||
|
||||
type Group string
|
||||
|
||||
const (
|
||||
GroupCPU Group = "CPU"
|
||||
GroupMEM Group = "MEM"
|
||||
GroupGPU Group = "GPU"
|
||||
GroupDISK Group = "DISK"
|
||||
GroupNET Group = "NET"
|
||||
GroupPSU Group = "PSU"
|
||||
)
|
||||
|
||||
// GroupForLotCategory maps pricelist lot_category codes into article groups.
|
||||
// Unknown/unrelated categories return ok=false.
|
||||
func GroupForLotCategory(cat string) (group Group, ok bool) {
|
||||
c := strings.ToUpper(strings.TrimSpace(cat))
|
||||
switch c {
|
||||
case "CPU":
|
||||
return GroupCPU, true
|
||||
case "MEM":
|
||||
return GroupMEM, true
|
||||
case "GPU":
|
||||
return GroupGPU, true
|
||||
case "M2", "SSD", "HDD", "EDSFF", "HHHL":
|
||||
return GroupDISK, true
|
||||
case "NIC", "HCA", "DPU":
|
||||
return GroupNET, true
|
||||
case "HBA":
|
||||
return GroupNET, true
|
||||
case "PSU", "PS":
|
||||
return GroupPSU, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveLotCategoriesStrict resolves categories for lotNames using local_pricelist_items.lot_category
|
||||
// for a given server pricelist id. If any lot is missing or has empty category, returns an error.
|
||||
func ResolveLotCategoriesStrict(local *localdb.LocalDB, serverPricelistID uint, lotNames []string) (map[string]string, error) {
|
||||
if local == nil {
|
||||
return nil, fmt.Errorf("local db is nil")
|
||||
}
|
||||
cats, err := local.GetLocalLotCategoriesByServerPricelistID(serverPricelistID, lotNames)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
missing := make([]string, 0)
|
||||
for _, lot := range lotNames {
|
||||
cat := strings.TrimSpace(cats[lot])
|
||||
if cat == "" {
|
||||
missing = append(missing, lot)
|
||||
continue
|
||||
}
|
||||
cats[lot] = cat
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
fallback, err := local.GetLocalComponentCategoriesByLotNames(missing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, lot := range missing {
|
||||
if cat := strings.TrimSpace(fallback[lot]); cat != "" {
|
||||
cats[lot] = cat
|
||||
}
|
||||
}
|
||||
for _, lot := range missing {
|
||||
if strings.TrimSpace(cats[lot]) == "" {
|
||||
return nil, &MissingCategoryForLotError{LotName: lot}
|
||||
}
|
||||
}
|
||||
}
|
||||
return cats, nil
|
||||
}
|
||||
|
||||
// NormalizeServerModel produces a stable article segment for the server model.
|
||||
func NormalizeServerModel(model string) string {
|
||||
trimmed := strings.TrimSpace(model)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
upper := strings.ToUpper(trimmed)
|
||||
var b strings.Builder
|
||||
for _, r := range upper {
|
||||
if r >= 'A' && r <= 'Z' {
|
||||
b.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
if r >= '0' && r <= '9' {
|
||||
b.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
if r == '.' {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
98
internal/article/categories_test.go
Normal file
98
internal/article/categories_test.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package article
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
)
|
||||
|
||||
func TestResolveLotCategoriesStrict_MissingCategoryReturnsError(t *testing.T) {
|
||||
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("init local db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||
ServerID: 1,
|
||||
Source: "estimate",
|
||||
Version: "S-2026-02-11-001",
|
||||
Name: "test",
|
||||
CreatedAt: time.Now(),
|
||||
SyncedAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatalf("save local pricelist: %v", err)
|
||||
}
|
||||
localPL, err := local.GetLocalPricelistByServerID(1)
|
||||
if err != nil {
|
||||
t.Fatalf("get local pricelist: %v", err)
|
||||
}
|
||||
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
||||
{PricelistID: localPL.ID, LotName: "CPU_A", LotCategory: "", Price: 10},
|
||||
}); err != nil {
|
||||
t.Fatalf("save local items: %v", err)
|
||||
}
|
||||
|
||||
_, err = ResolveLotCategoriesStrict(local, 1, []string{"CPU_A"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
if !errors.Is(err, ErrMissingCategoryForLot) {
|
||||
t.Fatalf("expected ErrMissingCategoryForLot, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLotCategoriesStrict_FallbackToLocalComponents(t *testing.T) {
|
||||
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("init local db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||
ServerID: 2,
|
||||
Source: "estimate",
|
||||
Version: "S-2026-02-11-002",
|
||||
Name: "test",
|
||||
CreatedAt: time.Now(),
|
||||
SyncedAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatalf("save local pricelist: %v", err)
|
||||
}
|
||||
localPL, err := local.GetLocalPricelistByServerID(2)
|
||||
if err != nil {
|
||||
t.Fatalf("get local pricelist: %v", err)
|
||||
}
|
||||
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
||||
{PricelistID: localPL.ID, LotName: "CPU_B", LotCategory: "", Price: 10},
|
||||
}); err != nil {
|
||||
t.Fatalf("save local items: %v", err)
|
||||
}
|
||||
if err := local.DB().Create(&localdb.LocalComponent{
|
||||
LotName: "CPU_B",
|
||||
Category: "CPU",
|
||||
LotDescription: "cpu",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("save local components: %v", err)
|
||||
}
|
||||
|
||||
cats, err := ResolveLotCategoriesStrict(local, 2, []string{"CPU_B"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected fallback, got error: %v", err)
|
||||
}
|
||||
if cats["CPU_B"] != "CPU" {
|
||||
t.Fatalf("expected CPU, got %q", cats["CPU_B"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupForLotCategory(t *testing.T) {
|
||||
if g, ok := GroupForLotCategory("cpu"); !ok || g != GroupCPU {
|
||||
t.Fatalf("expected cpu -> GroupCPU")
|
||||
}
|
||||
if g, ok := GroupForLotCategory("SFP"); ok || g != "" {
|
||||
t.Fatalf("expected SFP to be excluded")
|
||||
}
|
||||
}
|
||||
602
internal/article/generator.go
Normal file
602
internal/article/generator.go
Normal file
@@ -0,0 +1,602 @@
|
||||
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, "+")
|
||||
}
|
||||
66
internal/article/generator_test.go
Normal file
66
internal/article/generator_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
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)
|
||||
}
|
||||
@@ -20,6 +20,7 @@ type Config struct {
|
||||
Alerts AlertsConfig `yaml:"alerts"`
|
||||
Notifications NotificationsConfig `yaml:"notifications"`
|
||||
Logging LoggingConfig `yaml:"logging"`
|
||||
Backup BackupConfig `yaml:"backup"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
@@ -101,6 +102,10 @@ type LoggingConfig struct {
|
||||
FilePath string `yaml:"file_path"`
|
||||
}
|
||||
|
||||
type BackupConfig struct {
|
||||
Time string `yaml:"time"`
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -182,6 +187,10 @@ func (c *Config) setDefaults() {
|
||||
if c.Logging.Output == "" {
|
||||
c.Logging.Output = "stdout"
|
||||
}
|
||||
|
||||
if c.Backup.Time == "" {
|
||||
c.Backup.Time = "00:00"
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) Address() string {
|
||||
|
||||
@@ -61,7 +61,6 @@ func (h *ComponentHandler) List(c *gin.Context) {
|
||||
Category: lc.Category,
|
||||
CategoryName: lc.Category,
|
||||
Model: lc.Model,
|
||||
CurrentPrice: lc.CurrentPrice,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +86,6 @@ func (h *ComponentHandler) Get(c *gin.Context) {
|
||||
Category: component.Category,
|
||||
CategoryName: component.Category,
|
||||
Model: component.Model,
|
||||
CurrentPrice: component.CurrentPrice,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -3,34 +3,41 @@ package handlers
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/middleware"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ExportHandler struct {
|
||||
exportService *services.ExportService
|
||||
configService services.ConfigurationGetter
|
||||
componentService *services.ComponentService
|
||||
exportService *services.ExportService
|
||||
configService services.ConfigurationGetter
|
||||
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,
|
||||
exportService: exportService,
|
||||
configService: configService,
|
||||
projectService: projectService,
|
||||
}
|
||||
}
|
||||
|
||||
type ExportRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Items []struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
ProjectName string `json:"project_name"`
|
||||
ProjectUUID string `json:"project_uuid"`
|
||||
Article string `json:"article"`
|
||||
ServerCount int `json:"server_count"`
|
||||
PricelistID *uint `json:"pricelist_id"`
|
||||
Items []struct {
|
||||
LotName string `json:"lot_name" binding:"required"`
|
||||
Quantity int `json:"quantity" binding:"required,min=1"`
|
||||
UnitPrice float64 `json:"unit_price"`
|
||||
@@ -47,75 +54,162 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
||||
|
||||
data := h.buildExportData(&req)
|
||||
|
||||
csvData, err := h.exportService.ToCSV(data)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
// Validate before streaming (can return JSON error)
|
||||
if len(data.Configs) == 0 || len(data.Configs[0].Items) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"})
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%s %s SPEC.csv", time.Now().Format("2006-01-02"), req.Name)
|
||||
// Get project code for filename
|
||||
projectCode := req.ProjectName // legacy field: may contain code from frontend
|
||||
if projectCode == "" && req.ProjectUUID != "" {
|
||||
username := middleware.GetUsername(c)
|
||||
if project, err := h.projectService.GetByUUID(req.ProjectUUID, username); err == nil && project != nil {
|
||||
projectCode = project.Code
|
||||
}
|
||||
}
|
||||
if projectCode == "" {
|
||||
projectCode = req.Name
|
||||
}
|
||||
|
||||
// Set headers before streaming
|
||||
exportDate := data.CreatedAt
|
||||
articleSegment := sanitizeFilenameSegment(req.Article)
|
||||
if articleSegment == "" {
|
||||
articleSegment = "BOM"
|
||||
}
|
||||
filename := fmt.Sprintf("%s (%s) %s %s.csv", exportDate.Format("2006-01-02"), projectCode, req.Name, articleSegment)
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
c.Data(http.StatusOK, "text/csv; charset=utf-8", csvData)
|
||||
|
||||
// Stream CSV (cannot return JSON after this point)
|
||||
if err := h.exportService.ToCSV(c.Writer, data); err != nil {
|
||||
c.Error(err) // Log only
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData {
|
||||
items := make([]services.ExportItem, len(req.Items))
|
||||
var total float64
|
||||
|
||||
// buildExportData converts an ExportRequest into a ProjectExportData using a temporary Configuration model
|
||||
// so that ExportService.ConfigToExportData can resolve categories via localDB.
|
||||
func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ProjectExportData {
|
||||
configItems := make(models.ConfigItems, len(req.Items))
|
||||
for i, item := range req.Items {
|
||||
itemTotal := item.UnitPrice * float64(item.Quantity)
|
||||
|
||||
// Получаем информацию о компоненте для заполнения категории и описания
|
||||
componentView, err := h.componentService.GetByLotName(item.LotName)
|
||||
if err != nil {
|
||||
// Если не удалось получить информацию о компоненте, используем только основные данные
|
||||
items[i] = services.ExportItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: item.UnitPrice,
|
||||
TotalPrice: itemTotal,
|
||||
}
|
||||
} else {
|
||||
items[i] = services.ExportItem{
|
||||
LotName: item.LotName,
|
||||
Description: componentView.Description,
|
||||
Category: componentView.Category,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: item.UnitPrice,
|
||||
TotalPrice: itemTotal,
|
||||
}
|
||||
configItems[i] = models.ConfigItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: item.UnitPrice,
|
||||
}
|
||||
total += itemTotal
|
||||
}
|
||||
|
||||
return &services.ExportData{
|
||||
Name: req.Name,
|
||||
Items: items,
|
||||
Total: total,
|
||||
Notes: req.Notes,
|
||||
CreatedAt: time.Now(),
|
||||
serverCount := req.ServerCount
|
||||
if serverCount < 1 {
|
||||
serverCount = 1
|
||||
}
|
||||
|
||||
cfg := &models.Configuration{
|
||||
Article: req.Article,
|
||||
ServerCount: serverCount,
|
||||
PricelistID: req.PricelistID,
|
||||
Items: configItems,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
return h.exportService.ConfigToExportData(cfg)
|
||||
}
|
||||
|
||||
func sanitizeFilenameSegment(value string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return ""
|
||||
}
|
||||
replacer := strings.NewReplacer(
|
||||
"/", "_",
|
||||
"\\", "_",
|
||||
":", "_",
|
||||
"*", "_",
|
||||
"?", "_",
|
||||
"\"", "_",
|
||||
"<", "_",
|
||||
">", "_",
|
||||
"|", "_",
|
||||
)
|
||||
return strings.TrimSpace(replacer.Replace(value))
|
||||
}
|
||||
|
||||
func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
|
||||
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()})
|
||||
return
|
||||
}
|
||||
|
||||
data := h.exportService.ConfigToExportData(config, h.componentService)
|
||||
data := h.exportService.ConfigToExportData(config)
|
||||
|
||||
csvData, err := h.exportService.ToCSV(data)
|
||||
// Validate before streaming (can return JSON error)
|
||||
if len(data.Configs) == 0 || len(data.Configs[0].Items) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get project code for filename
|
||||
projectCode := config.Name // fallback: use config name if no project
|
||||
if config.ProjectUUID != nil && *config.ProjectUUID != "" {
|
||||
if project, err := h.projectService.GetByUUID(*config.ProjectUUID, username); err == nil && project != nil {
|
||||
projectCode = project.Code
|
||||
}
|
||||
}
|
||||
|
||||
// Set headers before streaming
|
||||
// Use price update time if available, otherwise creation time
|
||||
exportDate := config.CreatedAt
|
||||
if config.PriceUpdatedAt != nil {
|
||||
exportDate = *config.PriceUpdatedAt
|
||||
}
|
||||
filename := fmt.Sprintf("%s (%s) %s BOM.csv", exportDate.Format("2006-01-02"), projectCode, config.Name)
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
|
||||
// Stream CSV (cannot return JSON after this point)
|
||||
if err := h.exportService.ToCSV(c.Writer, data); err != nil {
|
||||
c.Error(err) // Log only
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ExportProjectCSV exports all active configurations of a project as a single CSV file.
|
||||
func (h *ExportHandler) ExportProjectCSV(c *gin.Context) {
|
||||
username := middleware.GetUsername(c)
|
||||
projectUUID := c.Param("uuid")
|
||||
|
||||
project, err := h.projectService.GetByUUID(projectUUID, username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.projectService.ListConfigurations(projectUUID, username, "active")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%s %s SPEC.csv", config.CreatedAt.Format("2006-01-02"), config.Name)
|
||||
if len(result.Configs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no configurations to export"})
|
||||
return
|
||||
}
|
||||
|
||||
data := h.exportService.ProjectToExportData(result.Configs)
|
||||
|
||||
// Filename: YYYY-MM-DD (ProjectCode) BOM.csv
|
||||
filename := fmt.Sprintf("%s (%s) BOM.csv", time.Now().Format("2006-01-02"), project.Code)
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
c.Data(http.StatusOK, "text/csv; charset=utf-8", csvData)
|
||||
|
||||
if err := h.exportService.ToCSV(c.Writer, data); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
305
internal/handlers/export_test.go
Normal file
305
internal/handlers/export_test.go
Normal file
@@ -0,0 +1,305 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Mock services for testing
|
||||
type mockConfigService struct {
|
||||
config *models.Configuration
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockConfigService) GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error) {
|
||||
return m.config, m.err
|
||||
}
|
||||
|
||||
|
||||
func TestExportCSV_Success(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// Create handler with mocks
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
||||
handler := NewExportHandler(
|
||||
exportSvc,
|
||||
&mockConfigService{},
|
||||
nil,
|
||||
)
|
||||
|
||||
// Create JSON request body
|
||||
jsonBody := `{
|
||||
"name": "Test Export",
|
||||
"items": [
|
||||
{
|
||||
"lot_name": "LOT-001",
|
||||
"quantity": 2,
|
||||
"unit_price": 100.50
|
||||
}
|
||||
],
|
||||
"notes": "Test notes"
|
||||
}`
|
||||
|
||||
// Create HTTP request
|
||||
req, _ := http.NewRequest("POST", "/api/export/csv", bytes.NewBufferString(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Create response recorder
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Create Gin context
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
|
||||
// Call handler
|
||||
handler.ExportCSV(c)
|
||||
|
||||
// Check status code
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Check Content-Type header
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if contentType != "text/csv; charset=utf-8" {
|
||||
t.Errorf("Expected Content-Type 'text/csv; charset=utf-8', got %q", contentType)
|
||||
}
|
||||
|
||||
// Check for BOM
|
||||
responseBody := w.Body.Bytes()
|
||||
if len(responseBody) < 3 {
|
||||
t.Fatalf("Response too short to contain BOM")
|
||||
}
|
||||
|
||||
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
||||
actualBOM := responseBody[:3]
|
||||
if !bytes.Equal(actualBOM, expectedBOM) {
|
||||
t.Errorf("UTF-8 BOM mismatch. Expected %v, got %v", expectedBOM, actualBOM)
|
||||
}
|
||||
|
||||
// Check semicolon delimiter in CSV
|
||||
reader := csv.NewReader(bytes.NewReader(responseBody[3:]))
|
||||
reader.Comma = ';'
|
||||
|
||||
header, err := reader.Read()
|
||||
if err != nil {
|
||||
t.Errorf("Failed to parse CSV header: %v", err)
|
||||
}
|
||||
|
||||
if len(header) != 8 {
|
||||
t.Errorf("Expected 8 columns, got %d", len(header))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportCSV_InvalidRequest(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
||||
handler := NewExportHandler(
|
||||
exportSvc,
|
||||
&mockConfigService{},
|
||||
nil,
|
||||
)
|
||||
|
||||
// Create invalid request (missing required field)
|
||||
req, _ := http.NewRequest("POST", "/api/export/csv", bytes.NewBufferString(`{"name": "Test"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
|
||||
handler.ExportCSV(c)
|
||||
|
||||
// Should return 400 Bad Request
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Should return JSON error
|
||||
var errResp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &errResp)
|
||||
if _, hasError := errResp["error"]; !hasError {
|
||||
t.Errorf("Expected error in JSON response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportCSV_EmptyItems(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
||||
handler := NewExportHandler(
|
||||
exportSvc,
|
||||
&mockConfigService{},
|
||||
nil,
|
||||
)
|
||||
|
||||
// Create request with empty items array - should fail binding validation
|
||||
req, _ := http.NewRequest("POST", "/api/export/csv", bytes.NewBufferString(`{"name":"Empty Export","items":[],"notes":""}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
|
||||
handler.ExportCSV(c)
|
||||
|
||||
// Should return 400 Bad Request (validation error from gin binding)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Logf("Status code: %d (expected 400 for empty items)", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportConfigCSV_Success(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// Mock configuration
|
||||
mockConfig := &models.Configuration{
|
||||
UUID: "test-uuid",
|
||||
Name: "Test Config",
|
||||
OwnerUsername: "testuser",
|
||||
Items: models.ConfigItems{
|
||||
{
|
||||
LotName: "LOT-001",
|
||||
Quantity: 1,
|
||||
UnitPrice: 100.0,
|
||||
},
|
||||
},
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
||||
handler := NewExportHandler(
|
||||
exportSvc,
|
||||
&mockConfigService{config: mockConfig},
|
||||
nil,
|
||||
)
|
||||
|
||||
// Create HTTP request
|
||||
req, _ := http.NewRequest("GET", "/api/configs/test-uuid/export", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
c.Params = gin.Params{
|
||||
{Key: "uuid", Value: "test-uuid"},
|
||||
}
|
||||
|
||||
// Mock middleware.GetUsername
|
||||
c.Set("username", "testuser")
|
||||
|
||||
handler.ExportConfigCSV(c)
|
||||
|
||||
// Check status code
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Check Content-Type header
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if contentType != "text/csv; charset=utf-8" {
|
||||
t.Errorf("Expected Content-Type 'text/csv; charset=utf-8', got %q", contentType)
|
||||
}
|
||||
|
||||
// Check for BOM
|
||||
responseBody := w.Body.Bytes()
|
||||
if len(responseBody) < 3 {
|
||||
t.Fatalf("Response too short to contain BOM")
|
||||
}
|
||||
|
||||
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
||||
actualBOM := responseBody[:3]
|
||||
if !bytes.Equal(actualBOM, expectedBOM) {
|
||||
t.Errorf("UTF-8 BOM mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportConfigCSV_NotFound(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
||||
handler := NewExportHandler(
|
||||
exportSvc,
|
||||
&mockConfigService{err: errors.New("config not found")},
|
||||
nil,
|
||||
)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/api/configs/nonexistent-uuid/export", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
c.Params = gin.Params{
|
||||
{Key: "uuid", Value: "nonexistent-uuid"},
|
||||
}
|
||||
c.Set("username", "testuser")
|
||||
|
||||
handler.ExportConfigCSV(c)
|
||||
|
||||
// Should return 404 Not Found
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("Expected status 404, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Should return JSON error
|
||||
var errResp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &errResp)
|
||||
if _, hasError := errResp["error"]; !hasError {
|
||||
t.Errorf("Expected error in JSON response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportConfigCSV_EmptyItems(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// Mock configuration with empty items
|
||||
mockConfig := &models.Configuration{
|
||||
UUID: "test-uuid",
|
||||
Name: "Empty Config",
|
||||
OwnerUsername: "testuser",
|
||||
Items: models.ConfigItems{},
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
||||
handler := NewExportHandler(
|
||||
exportSvc,
|
||||
&mockConfigService{config: mockConfig},
|
||||
nil,
|
||||
)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/api/configs/test-uuid/export", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
c.Params = gin.Params{
|
||||
{Key: "uuid", Value: "test-uuid"},
|
||||
}
|
||||
c.Set("username", "testuser")
|
||||
|
||||
handler.ExportConfigCSV(c)
|
||||
|
||||
// Should return 400 Bad Request
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Should return JSON error
|
||||
var errResp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &errResp)
|
||||
if _, hasError := errResp["error"]; !hasError {
|
||||
t.Errorf("Expected error in JSON response")
|
||||
}
|
||||
}
|
||||
@@ -46,8 +46,30 @@ func (h *PricelistHandler) List(c *gin.Context) {
|
||||
}
|
||||
localPLs = filtered
|
||||
}
|
||||
if activeOnly {
|
||||
// Local cache stores only active snapshots for normal operations.
|
||||
type pricelistWithCount struct {
|
||||
pricelist localdb.LocalPricelist
|
||||
itemCount int64
|
||||
usageCount int
|
||||
}
|
||||
withCounts := make([]pricelistWithCount, 0, len(localPLs))
|
||||
for _, lpl := range localPLs {
|
||||
itemCount := h.localDB.CountLocalPricelistItems(lpl.ID)
|
||||
if activeOnly && itemCount == 0 {
|
||||
continue
|
||||
}
|
||||
usageCount := 0
|
||||
if lpl.IsUsed {
|
||||
usageCount = 1
|
||||
}
|
||||
withCounts = append(withCounts, pricelistWithCount{
|
||||
pricelist: lpl,
|
||||
itemCount: itemCount,
|
||||
usageCount: usageCount,
|
||||
})
|
||||
}
|
||||
localPLs = localPLs[:0]
|
||||
for _, row := range withCounts {
|
||||
localPLs = append(localPLs, row.pricelist)
|
||||
}
|
||||
sort.SliceStable(localPLs, func(i, j int) bool { return localPLs[i].CreatedAt.After(localPLs[j].CreatedAt) })
|
||||
total := len(localPLs)
|
||||
@@ -62,10 +84,14 @@ func (h *PricelistHandler) List(c *gin.Context) {
|
||||
pageSlice := localPLs[start:end]
|
||||
summaries := make([]map[string]interface{}, 0, len(pageSlice))
|
||||
for _, lpl := range pageSlice {
|
||||
itemCount := h.localDB.CountLocalPricelistItems(lpl.ID)
|
||||
itemCount := int64(0)
|
||||
usageCount := 0
|
||||
if lpl.IsUsed {
|
||||
usageCount = 1
|
||||
for _, row := range withCounts {
|
||||
if row.pricelist.ID == lpl.ID {
|
||||
itemCount = row.itemCount
|
||||
usageCount = row.usageCount
|
||||
break
|
||||
}
|
||||
}
|
||||
summaries = append(summaries, map[string]interface{}{
|
||||
"id": lpl.ServerID,
|
||||
@@ -157,15 +183,11 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
|
||||
}
|
||||
resultItems := make([]gin.H, 0, len(items))
|
||||
for _, item := range items {
|
||||
category := ""
|
||||
if parts := strings.SplitN(item.LotName, "_", 2); len(parts) > 0 {
|
||||
category = parts[0]
|
||||
}
|
||||
resultItems = append(resultItems, gin.H{
|
||||
"id": item.ID,
|
||||
"lot_name": item.LotName,
|
||||
"price": item.Price,
|
||||
"category": category,
|
||||
"category": item.LotCategory,
|
||||
"available_qty": item.AvailableQty,
|
||||
"partnumbers": []string(item.Partnumbers),
|
||||
})
|
||||
|
||||
161
internal/handlers/pricelist_test.go
Normal file
161
internal/handlers/pricelist_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestPricelistGetItems_ReturnsLotCategoryFromLocalPricelistItems(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("init local db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||
ServerID: 1,
|
||||
Source: "estimate",
|
||||
Version: "S-2026-02-11-001",
|
||||
Name: "test",
|
||||
CreatedAt: time.Now(),
|
||||
SyncedAt: time.Now(),
|
||||
IsUsed: false,
|
||||
}); err != nil {
|
||||
t.Fatalf("save local pricelist: %v", err)
|
||||
}
|
||||
localPL, err := local.GetLocalPricelistByServerID(1)
|
||||
if err != nil {
|
||||
t.Fatalf("get local pricelist: %v", err)
|
||||
}
|
||||
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
||||
{
|
||||
PricelistID: localPL.ID,
|
||||
LotName: "NO_UNDERSCORE_NAME",
|
||||
LotCategory: "CPU",
|
||||
Price: 10,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save local pricelist items: %v", err)
|
||||
}
|
||||
|
||||
h := NewPricelistHandler(local)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/api/pricelists/1/items?page=1&per_page=50", nil)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
c.Params = gin.Params{{Key: "id", Value: "1"}}
|
||||
|
||||
h.GetItems(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Items []struct {
|
||||
LotName string `json:"lot_name"`
|
||||
Category string `json:"category"`
|
||||
UnitPrice any `json:"price"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if len(resp.Items) != 1 {
|
||||
t.Fatalf("expected 1 item, got %d", len(resp.Items))
|
||||
}
|
||||
if resp.Items[0].LotName != "NO_UNDERSCORE_NAME" {
|
||||
t.Fatalf("expected lot_name NO_UNDERSCORE_NAME, got %q", resp.Items[0].LotName)
|
||||
}
|
||||
if resp.Items[0].Category != "CPU" {
|
||||
t.Fatalf("expected category CPU, got %q", resp.Items[0].Category)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPricelistList_ActiveOnlyExcludesPricelistsWithoutItems(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
local, err := localdb.New(filepath.Join(t.TempDir(), "local_active_only.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("init local db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||
ServerID: 10,
|
||||
Source: "estimate",
|
||||
Version: "E-1",
|
||||
Name: "with-items",
|
||||
CreatedAt: time.Now().Add(-time.Minute),
|
||||
SyncedAt: time.Now().Add(-time.Minute),
|
||||
}); err != nil {
|
||||
t.Fatalf("save with-items pricelist: %v", err)
|
||||
}
|
||||
withItems, err := local.GetLocalPricelistByServerID(10)
|
||||
if err != nil {
|
||||
t.Fatalf("load with-items pricelist: %v", err)
|
||||
}
|
||||
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
||||
{
|
||||
PricelistID: withItems.ID,
|
||||
LotName: "CPU_X",
|
||||
LotCategory: "CPU",
|
||||
Price: 100,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save with-items pricelist items: %v", err)
|
||||
}
|
||||
|
||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||
ServerID: 11,
|
||||
Source: "estimate",
|
||||
Version: "E-2",
|
||||
Name: "without-items",
|
||||
CreatedAt: time.Now(),
|
||||
SyncedAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatalf("save without-items pricelist: %v", err)
|
||||
}
|
||||
|
||||
h := NewPricelistHandler(local)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/api/pricelists?source=estimate&active_only=true", nil)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
|
||||
h.List(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Pricelists []struct {
|
||||
ID uint `json:"id"`
|
||||
} `json:"pricelists"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if resp.Total != 1 {
|
||||
t.Fatalf("expected total=1, got %d", resp.Total)
|
||||
}
|
||||
if len(resp.Pricelists) != 1 {
|
||||
t.Fatalf("expected 1 pricelist, got %d", len(resp.Pricelists))
|
||||
}
|
||||
if resp.Pricelists[0].ID != 10 {
|
||||
t.Fatalf("expected pricelist id=10, got %d", resp.Pricelists[0].ID)
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -28,7 +26,7 @@ type SetupHandler struct {
|
||||
restartSig chan struct{}
|
||||
}
|
||||
|
||||
func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, templatesPath string, restartSig chan struct{}) (*SetupHandler, error) {
|
||||
func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, _ string, restartSig chan struct{}) (*SetupHandler, error) {
|
||||
funcMap := template.FuncMap{
|
||||
"sub": func(a, b int) int { return a - b },
|
||||
"add": func(a, b int) int { return a + b },
|
||||
@@ -37,14 +35,9 @@ func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, te
|
||||
templates := make(map[string]*template.Template)
|
||||
|
||||
// Load setup template (standalone, no base needed)
|
||||
setupPath := filepath.Join(templatesPath, "setup.html")
|
||||
var tmpl *template.Template
|
||||
var err error
|
||||
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
|
||||
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(setupPath)
|
||||
} else {
|
||||
tmpl, err = template.New("").Funcs(funcMap).ParseFS(qfassets.TemplatesFS, "web/templates/setup.html")
|
||||
}
|
||||
tmpl, err = template.New("").Funcs(funcMap).ParseFS(qfassets.TemplatesFS, "web/templates/setup.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing setup template: %w", err)
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
stdsync "sync"
|
||||
"time"
|
||||
|
||||
@@ -32,16 +30,9 @@ type SyncHandler struct {
|
||||
}
|
||||
|
||||
// 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, _ string, autoSyncInterval time.Duration) (*SyncHandler, error) {
|
||||
// Load sync_status partial template
|
||||
partialPath := filepath.Join(templatesPath, "partials", "sync_status.html")
|
||||
var tmpl *template.Template
|
||||
var err error
|
||||
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
|
||||
tmpl, err = template.ParseFiles(partialPath)
|
||||
} else {
|
||||
tmpl, err = template.ParseFS(qfassets.TemplatesFS, "web/templates/partials/sync_status.html")
|
||||
}
|
||||
tmpl, err := template.ParseFS(qfassets.TemplatesFS, "web/templates/partials/sync_status.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -419,6 +410,26 @@ func (h *SyncHandler) GetPendingChanges(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// RepairPendingChanges attempts to repair errored pending changes
|
||||
// POST /api/sync/repair
|
||||
func (h *SyncHandler) RepairPendingChanges(c *gin.Context) {
|
||||
repaired, remainingErrors, err := h.localDB.RepairPendingChanges()
|
||||
if err != nil {
|
||||
slog.Error("repair pending changes failed", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"repaired": repaired,
|
||||
"remaining_errors": remainingErrors,
|
||||
})
|
||||
}
|
||||
|
||||
// SyncInfoResponse represents sync information for the modal
|
||||
type SyncInfoResponse struct {
|
||||
// Connection
|
||||
|
||||
64
internal/handlers/sync_readiness_test.go
Normal file
64
internal/handlers/sync_readiness_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestSyncReadinessOfflineBlocked(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
dir := t.TempDir()
|
||||
local, err := localdb.New(filepath.Join(dir, "qfs.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("init local db: %v", err)
|
||||
}
|
||||
|
||||
service := syncsvc.NewService(nil, local)
|
||||
h, err := NewSyncHandler(local, service, nil, filepath.Join("web", "templates"), 5*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("new sync handler: %v", err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.GET("/api/sync/readiness", h.GetReadiness)
|
||||
router.POST("/api/sync/push", h.PushPendingChanges)
|
||||
|
||||
readinessResp := httptest.NewRecorder()
|
||||
readinessReq, _ := http.NewRequest(http.MethodGet, "/api/sync/readiness", nil)
|
||||
router.ServeHTTP(readinessResp, readinessReq)
|
||||
if readinessResp.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected readiness status: %d", readinessResp.Code)
|
||||
}
|
||||
|
||||
var readinessBody map[string]any
|
||||
if err := json.Unmarshal(readinessResp.Body.Bytes(), &readinessBody); err != nil {
|
||||
t.Fatalf("decode readiness body: %v", err)
|
||||
}
|
||||
if blocked, _ := readinessBody["blocked"].(bool); !blocked {
|
||||
t.Fatalf("expected blocked readiness, got %v", readinessBody["blocked"])
|
||||
}
|
||||
|
||||
pushResp := httptest.NewRecorder()
|
||||
pushReq, _ := http.NewRequest(http.MethodPost, "/api/sync/push", nil)
|
||||
router.ServeHTTP(pushResp, pushReq)
|
||||
if pushResp.Code != http.StatusLocked {
|
||||
t.Fatalf("expected 423 for blocked sync push, got %d body=%s", pushResp.Code, pushResp.Body.String())
|
||||
}
|
||||
|
||||
var pushBody map[string]any
|
||||
if err := json.Unmarshal(pushResp.Body.Bytes(), &pushBody); err != nil {
|
||||
t.Fatalf("decode push body: %v", err)
|
||||
}
|
||||
if pushBody["reason_text"] == nil || pushBody["reason_text"] == "" {
|
||||
t.Fatalf("expected reason_text in blocked response, got %v", pushBody)
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,6 @@ package handlers
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
qfassets "git.mchus.pro/mchus/quoteforge"
|
||||
@@ -17,7 +15,7 @@ type WebHandler struct {
|
||||
componentService *services.ComponentService
|
||||
}
|
||||
|
||||
func NewWebHandler(templatesPath string, componentService *services.ComponentService) (*WebHandler, error) {
|
||||
func NewWebHandler(_ string, componentService *services.ComponentService) (*WebHandler, error) {
|
||||
funcMap := template.FuncMap{
|
||||
"sub": func(a, b int) int { return a - b },
|
||||
"add": func(a, b int) int { return a + b },
|
||||
@@ -60,27 +58,16 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
|
||||
}
|
||||
|
||||
templates := make(map[string]*template.Template)
|
||||
basePath := filepath.Join(templatesPath, "base.html")
|
||||
useDisk := false
|
||||
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
|
||||
useDisk = true
|
||||
}
|
||||
|
||||
// Load each page template with base
|
||||
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", "pricelists.html", "pricelist_detail.html", "config_revisions.html"}
|
||||
for _, page := range simplePages {
|
||||
pagePath := filepath.Join(templatesPath, page)
|
||||
var tmpl *template.Template
|
||||
var err error
|
||||
if useDisk {
|
||||
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(basePath, pagePath)
|
||||
} else {
|
||||
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
||||
qfassets.TemplatesFS,
|
||||
"web/templates/base.html",
|
||||
"web/templates/"+page,
|
||||
)
|
||||
}
|
||||
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
||||
qfassets.TemplatesFS,
|
||||
"web/templates/base.html",
|
||||
"web/templates/"+page,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -88,20 +75,14 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
|
||||
}
|
||||
|
||||
// Index page needs components_list.html as well
|
||||
indexPath := filepath.Join(templatesPath, "index.html")
|
||||
componentsListPath := filepath.Join(templatesPath, "components_list.html")
|
||||
var indexTmpl *template.Template
|
||||
var err error
|
||||
if useDisk {
|
||||
indexTmpl, err = template.New("").Funcs(funcMap).ParseFiles(basePath, indexPath, componentsListPath)
|
||||
} else {
|
||||
indexTmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
||||
qfassets.TemplatesFS,
|
||||
"web/templates/base.html",
|
||||
"web/templates/index.html",
|
||||
"web/templates/components_list.html",
|
||||
)
|
||||
}
|
||||
indexTmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
||||
qfassets.TemplatesFS,
|
||||
"web/templates/base.html",
|
||||
"web/templates/index.html",
|
||||
"web/templates/components_list.html",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -110,17 +91,12 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
|
||||
// Load partial templates (no base needed)
|
||||
partials := []string{"components_list.html"}
|
||||
for _, partial := range partials {
|
||||
partialPath := filepath.Join(templatesPath, partial)
|
||||
var tmpl *template.Template
|
||||
var err error
|
||||
if useDisk {
|
||||
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(partialPath)
|
||||
} else {
|
||||
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
||||
qfassets.TemplatesFS,
|
||||
"web/templates/"+partial,
|
||||
)
|
||||
}
|
||||
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
||||
qfassets.TemplatesFS,
|
||||
"web/templates/"+partial,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -147,8 +123,8 @@ func (h *WebHandler) render(c *gin.Context, name string, data gin.H) {
|
||||
}
|
||||
|
||||
func (h *WebHandler) Index(c *gin.Context) {
|
||||
// Redirect to configs page - configurator is accessed via /configurator?uuid=...
|
||||
c.Redirect(302, "/configs")
|
||||
// Redirect to projects page - configurator is accessed via /configurator?uuid=...
|
||||
c.Redirect(302, "/projects")
|
||||
}
|
||||
|
||||
func (h *WebHandler) Configurator(c *gin.Context) {
|
||||
@@ -197,6 +173,13 @@ func (h *WebHandler) ProjectDetail(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *WebHandler) ConfigRevisions(c *gin.Context) {
|
||||
h.render(c, "config_revisions.html", gin.H{
|
||||
"ActivePage": "configs",
|
||||
"ConfigUUID": c.Param("uuid"),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *WebHandler) Pricelists(c *gin.Context) {
|
||||
h.render(c, "pricelists.html", gin.H{"ActivePage": "pricelists"})
|
||||
}
|
||||
|
||||
@@ -28,14 +28,13 @@ type ComponentSyncResult struct {
|
||||
func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
// Query to join lot with qt_lot_metadata
|
||||
// Query to join lot with qt_lot_metadata (metadata only, no pricing)
|
||||
// Use LEFT JOIN to include lots without metadata
|
||||
type componentRow struct {
|
||||
LotName string
|
||||
LotDescription string
|
||||
Category *string
|
||||
Model *string
|
||||
CurrentPrice *float64
|
||||
}
|
||||
|
||||
var rows []componentRow
|
||||
@@ -44,8 +43,7 @@ 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.current_price
|
||||
m.model
|
||||
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
|
||||
@@ -100,8 +98,6 @@ 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)
|
||||
|
||||
@@ -221,11 +217,6 @@ 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 {
|
||||
@@ -251,6 +242,31 @@ func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) {
|
||||
return &component, nil
|
||||
}
|
||||
|
||||
// GetLocalComponentCategoriesByLotNames returns category for each lot_name in the local component cache.
|
||||
// Missing lots are not included in the map; caller is responsible for strict validation.
|
||||
func (l *LocalDB) GetLocalComponentCategoriesByLotNames(lotNames []string) (map[string]string, error) {
|
||||
result := make(map[string]string, len(lotNames))
|
||||
if len(lotNames) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type row struct {
|
||||
LotName string `gorm:"column:lot_name"`
|
||||
Category string `gorm:"column:category"`
|
||||
}
|
||||
var rows []row
|
||||
if err := l.db.Model(&LocalComponent{}).
|
||||
Select("lot_name, category").
|
||||
Where("lot_name IN ?", lotNames).
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
result[r.LotName] = r.Category
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetLocalComponentCategories returns distinct categories from local components
|
||||
func (l *LocalDB) GetLocalComponentCategories() ([]string, error) {
|
||||
var categories []string
|
||||
@@ -311,99 +327,3 @@ 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, load from latest estimate pricelist.
|
||||
var latestPricelist LocalPricelist
|
||||
if err := l.db.Where("source = ?", "estimate").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,8 +28,12 @@ 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,
|
||||
Line: cfg.Line,
|
||||
PriceUpdatedAt: cfg.PriceUpdatedAt,
|
||||
CreatedAt: cfg.CreatedAt,
|
||||
UpdatedAt: time.Now(),
|
||||
@@ -72,8 +76,12 @@ 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,
|
||||
Line: local.Line,
|
||||
PriceUpdatedAt: local.PriceUpdatedAt,
|
||||
CreatedAt: local.CreatedAt,
|
||||
}
|
||||
@@ -85,6 +93,9 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
|
||||
userID := local.OriginalUserID
|
||||
cfg.UserID = &userID
|
||||
}
|
||||
if local.CurrentVersion != nil {
|
||||
cfg.CurrentVersionNo = local.CurrentVersion.VersionNo
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
@@ -100,6 +111,8 @@ func ProjectToLocal(project *models.Project) *LocalProject {
|
||||
local := &LocalProject{
|
||||
UUID: project.UUID,
|
||||
OwnerUsername: project.OwnerUsername,
|
||||
Code: project.Code,
|
||||
Variant: project.Variant,
|
||||
Name: project.Name,
|
||||
TrackerURL: project.TrackerURL,
|
||||
IsActive: project.IsActive,
|
||||
@@ -119,6 +132,8 @@ func LocalToProject(local *LocalProject) *models.Project {
|
||||
project := &models.Project{
|
||||
UUID: local.UUID,
|
||||
OwnerUsername: local.OwnerUsername,
|
||||
Code: local.Code,
|
||||
Variant: local.Variant,
|
||||
Name: local.Name,
|
||||
TrackerURL: local.TrackerURL,
|
||||
IsActive: local.IsActive,
|
||||
@@ -169,6 +184,7 @@ func PricelistItemToLocal(item *models.PricelistItem, localPricelistID uint) *Lo
|
||||
return &LocalPricelistItem{
|
||||
PricelistID: localPricelistID,
|
||||
LotName: item.LotName,
|
||||
LotCategory: item.LotCategory,
|
||||
Price: item.Price,
|
||||
AvailableQty: item.AvailableQty,
|
||||
Partnumbers: partnumbers,
|
||||
@@ -183,6 +199,7 @@ func LocalToPricelistItem(local *LocalPricelistItem, serverPricelistID uint) *mo
|
||||
ID: local.ID,
|
||||
PricelistID: serverPricelistID,
|
||||
LotName: local.LotName,
|
||||
LotCategory: local.LotCategory,
|
||||
Price: local.Price,
|
||||
AvailableQty: local.AvailableQty,
|
||||
Partnumbers: partnumbers,
|
||||
@@ -213,17 +230,14 @@ 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,
|
||||
CurrentPrice: local.CurrentPrice,
|
||||
LotName: local.LotName,
|
||||
Model: local.Model,
|
||||
Lot: &models.Lot{
|
||||
LotName: local.LotName,
|
||||
LotDescription: local.LotDescription,
|
||||
|
||||
34
internal/localdb/converters_test.go
Normal file
34
internal/localdb/converters_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestRunLocalMigrationsBackfillsExistingConfigurations(t *testing.T) {
|
||||
@@ -125,3 +127,189 @@ func TestRunLocalMigrationsFixesPricelistVersionUniqueIndex(t *testing.T) {
|
||||
t.Fatalf("expected 2 pricelists, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLocalMigrationsDeduplicatesConfigurationVersionsBySpecAndPrice(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "versions_dedup.db")
|
||||
|
||||
local, err := New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open localdb: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
cfg := &LocalConfiguration{
|
||||
UUID: "dedup-cfg",
|
||||
Name: "Dedup",
|
||||
Items: LocalConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 100}},
|
||||
ServerCount: 1,
|
||||
SyncStatus: "pending",
|
||||
OriginalUsername: "tester",
|
||||
IsActive: true,
|
||||
}
|
||||
if err := local.SaveConfiguration(cfg); err != nil {
|
||||
t.Fatalf("save seed config: %v", err)
|
||||
}
|
||||
|
||||
baseV1Data, err := BuildConfigurationSnapshot(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("build v1 snapshot: %v", err)
|
||||
}
|
||||
baseV1 := LocalConfigurationVersion{
|
||||
ID: uuid.NewString(),
|
||||
ConfigurationUUID: cfg.UUID,
|
||||
VersionNo: 1,
|
||||
Data: baseV1Data,
|
||||
AppVersion: "test",
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := local.DB().Create(&baseV1).Error; err != nil {
|
||||
t.Fatalf("insert base v1: %v", err)
|
||||
}
|
||||
if err := local.DB().Model(&LocalConfiguration{}).
|
||||
Where("uuid = ?", cfg.UUID).
|
||||
Update("current_version_id", baseV1.ID).Error; err != nil {
|
||||
t.Fatalf("set current_version_id to v1: %v", err)
|
||||
}
|
||||
|
||||
v2 := LocalConfigurationVersion{
|
||||
ID: uuid.NewString(),
|
||||
ConfigurationUUID: cfg.UUID,
|
||||
VersionNo: 2,
|
||||
Data: baseV1.Data,
|
||||
AppVersion: "test",
|
||||
CreatedAt: time.Now().Add(1 * time.Second),
|
||||
}
|
||||
if err := local.DB().Create(&v2).Error; err != nil {
|
||||
t.Fatalf("insert duplicate v2: %v", err)
|
||||
}
|
||||
|
||||
modified := *cfg
|
||||
modified.Items = LocalConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 100}}
|
||||
total := modified.Items.Total()
|
||||
modified.TotalPrice = &total
|
||||
modified.UpdatedAt = time.Now()
|
||||
v3Data, err := BuildConfigurationSnapshot(&modified)
|
||||
if err != nil {
|
||||
t.Fatalf("build v3 snapshot: %v", err)
|
||||
}
|
||||
|
||||
v3 := LocalConfigurationVersion{
|
||||
ID: uuid.NewString(),
|
||||
ConfigurationUUID: cfg.UUID,
|
||||
VersionNo: 3,
|
||||
Data: v3Data,
|
||||
AppVersion: "test",
|
||||
CreatedAt: time.Now().Add(2 * time.Second),
|
||||
}
|
||||
if err := local.DB().Create(&v3).Error; err != nil {
|
||||
t.Fatalf("insert v3: %v", err)
|
||||
}
|
||||
|
||||
v4 := LocalConfigurationVersion{
|
||||
ID: uuid.NewString(),
|
||||
ConfigurationUUID: cfg.UUID,
|
||||
VersionNo: 4,
|
||||
Data: v3Data,
|
||||
AppVersion: "test",
|
||||
CreatedAt: time.Now().Add(3 * time.Second),
|
||||
}
|
||||
if err := local.DB().Create(&v4).Error; err != nil {
|
||||
t.Fatalf("insert duplicate v4: %v", err)
|
||||
}
|
||||
|
||||
if err := local.DB().Model(&LocalConfiguration{}).
|
||||
Where("uuid = ?", cfg.UUID).
|
||||
Update("current_version_id", v4.ID).Error; err != nil {
|
||||
t.Fatalf("point current_version_id to duplicate v4: %v", err)
|
||||
}
|
||||
|
||||
if err := local.DB().Where("id = ?", "2026_02_19_configuration_versions_dedup_spec_price").
|
||||
Delete(&LocalSchemaMigration{}).Error; err != nil {
|
||||
t.Fatalf("delete dedup migration record: %v", err)
|
||||
}
|
||||
|
||||
if err := runLocalMigrations(local.DB()); err != nil {
|
||||
t.Fatalf("rerun local migrations: %v", err)
|
||||
}
|
||||
|
||||
var versions []LocalConfigurationVersion
|
||||
if err := local.DB().Where("configuration_uuid = ?", cfg.UUID).
|
||||
Order("version_no ASC").
|
||||
Find(&versions).Error; err != nil {
|
||||
t.Fatalf("load versions after dedup: %v", err)
|
||||
}
|
||||
if len(versions) != 2 {
|
||||
t.Fatalf("expected 2 versions after dedup, got %d", len(versions))
|
||||
}
|
||||
if versions[0].VersionNo != 1 || versions[1].VersionNo != 3 {
|
||||
t.Fatalf("expected kept version numbers [1,3], got [%d,%d]", versions[0].VersionNo, versions[1].VersionNo)
|
||||
}
|
||||
|
||||
var after LocalConfiguration
|
||||
if err := local.DB().Where("uuid = ?", cfg.UUID).First(&after).Error; err != nil {
|
||||
t.Fatalf("load config after dedup: %v", err)
|
||||
}
|
||||
if after.CurrentVersionID == nil || *after.CurrentVersionID != v3.ID {
|
||||
t.Fatalf("expected current_version_id to point to kept latest version v3")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLocalMigrationsBackfillsConfigurationLineNo(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "line_no_backfill.db")
|
||||
|
||||
local, err := New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open localdb: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
projectUUID := "project-line"
|
||||
cfg1 := &LocalConfiguration{
|
||||
UUID: "line-cfg-1",
|
||||
ProjectUUID: &projectUUID,
|
||||
Name: "Cfg 1",
|
||||
Items: LocalConfigItems{},
|
||||
SyncStatus: "pending",
|
||||
OriginalUsername: "tester",
|
||||
IsActive: true,
|
||||
CreatedAt: time.Now().Add(-2 * time.Hour),
|
||||
}
|
||||
cfg2 := &LocalConfiguration{
|
||||
UUID: "line-cfg-2",
|
||||
ProjectUUID: &projectUUID,
|
||||
Name: "Cfg 2",
|
||||
Items: LocalConfigItems{},
|
||||
SyncStatus: "pending",
|
||||
OriginalUsername: "tester",
|
||||
IsActive: true,
|
||||
CreatedAt: time.Now().Add(-1 * time.Hour),
|
||||
}
|
||||
if err := local.SaveConfiguration(cfg1); err != nil {
|
||||
t.Fatalf("save cfg1: %v", err)
|
||||
}
|
||||
if err := local.SaveConfiguration(cfg2); err != nil {
|
||||
t.Fatalf("save cfg2: %v", err)
|
||||
}
|
||||
|
||||
if err := local.DB().Model(&LocalConfiguration{}).Where("uuid IN ?", []string{cfg1.UUID, cfg2.UUID}).Update("line_no", 0).Error; err != nil {
|
||||
t.Fatalf("reset line_no: %v", err)
|
||||
}
|
||||
if err := local.DB().Where("id = ?", "2026_02_19_local_config_line_no").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)
|
||||
}
|
||||
|
||||
var rows []LocalConfiguration
|
||||
if err := local.DB().Where("uuid IN ?", []string{cfg1.UUID, cfg2.UUID}).Order("created_at ASC").Find(&rows).Error; err != nil {
|
||||
t.Fatalf("load configurations: %v", err)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("expected 2 configurations, got %d", len(rows))
|
||||
}
|
||||
if rows[0].Line != 10 || rows[1].Line != 20 {
|
||||
t.Fatalf("expected line_no [10,20], got [%d,%d]", rows[0].Line, rows[1].Line)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appstate"
|
||||
"github.com/glebarez/sqlite"
|
||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||
uuidpkg "github.com/google/uuid"
|
||||
@@ -41,6 +42,49 @@ type LocalDB struct {
|
||||
path string
|
||||
}
|
||||
|
||||
// ResetData clears local data tables while keeping connection settings.
|
||||
// It does not drop schema or connection_settings.
|
||||
func ResetData(dbPath string) error {
|
||||
if strings.TrimSpace(dbPath) == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("stat local db: %w", err)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening sqlite database: %w", err)
|
||||
}
|
||||
|
||||
// Order does not matter because we use DELETEs without FK constraints in SQLite.
|
||||
tables := []string{
|
||||
"local_projects",
|
||||
"local_configurations",
|
||||
"local_configuration_versions",
|
||||
"local_pricelists",
|
||||
"local_pricelist_items",
|
||||
"local_components",
|
||||
"local_remote_migrations_applied",
|
||||
"local_sync_guard_state",
|
||||
"pending_changes",
|
||||
"app_settings",
|
||||
}
|
||||
for _, table := range tables {
|
||||
if err := db.Exec("DELETE FROM " + table).Error; err != nil {
|
||||
return fmt.Errorf("clear %s: %w", table, err)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("local database data reset", "path", dbPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// New creates a new LocalDB instance
|
||||
func New(dbPath string) (*LocalDB, error) {
|
||||
// Ensure directory exists
|
||||
@@ -49,6 +93,14 @@ func New(dbPath string) (*LocalDB, error) {
|
||||
return nil, fmt.Errorf("creating data directory: %w", err)
|
||||
}
|
||||
|
||||
if cfgPath, err := appstate.ResolveConfigPathNearDB("", dbPath); err == nil {
|
||||
if _, err := appstate.EnsureRotatingLocalBackup(dbPath, cfgPath); err != nil {
|
||||
return nil, fmt.Errorf("backup local data: %w", err)
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("resolve config path: %w", err)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
@@ -56,10 +108,31 @@ func New(dbPath string) (*LocalDB, error) {
|
||||
return nil, fmt.Errorf("opening sqlite database: %w", err)
|
||||
}
|
||||
|
||||
if err := ensureLocalProjectsTable(db); err != nil {
|
||||
return nil, fmt.Errorf("ensure local_projects table: %w", err)
|
||||
}
|
||||
|
||||
// Preflight: ensure local_projects has non-null UUIDs before AutoMigrate rebuilds tables.
|
||||
if db.Migrator().HasTable(&LocalProject{}) {
|
||||
if !db.Migrator().HasColumn(&LocalProject{}, "uuid") {
|
||||
if err := db.Exec(`ALTER TABLE local_projects ADD COLUMN uuid TEXT`).Error; err != nil {
|
||||
return nil, fmt.Errorf("adding local_projects.uuid: %w", err)
|
||||
}
|
||||
}
|
||||
var ids []uint
|
||||
if err := db.Raw(`SELECT id FROM local_projects WHERE uuid IS NULL OR uuid = ''`).Scan(&ids).Error; err != nil {
|
||||
return nil, fmt.Errorf("finding local_projects without uuid: %w", err)
|
||||
}
|
||||
for _, id := range ids {
|
||||
if err := db.Exec(`UPDATE local_projects SET uuid = ? WHERE id = ?`, uuidpkg.New().String(), id).Error; err != nil {
|
||||
return nil, fmt.Errorf("backfilling local_projects.uuid: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-migrate all local tables
|
||||
if err := db.AutoMigrate(
|
||||
&ConnectionSettings{},
|
||||
&LocalProject{},
|
||||
&LocalConfiguration{},
|
||||
&LocalConfigurationVersion{},
|
||||
&LocalPricelist{},
|
||||
@@ -84,6 +157,38 @@ func New(dbPath string) (*LocalDB, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ensureLocalProjectsTable(db *gorm.DB) error {
|
||||
if db.Migrator().HasTable(&LocalProject{}) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE local_projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
server_id INTEGER NULL,
|
||||
owner_username TEXT NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
variant TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NULL,
|
||||
tracker_url TEXT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
is_system INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME,
|
||||
synced_at DATETIME NULL,
|
||||
sync_status TEXT DEFAULT 'local'
|
||||
)`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_owner_username ON local_projects(owner_username)`).Error
|
||||
_ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_is_active ON local_projects(is_active)`).Error
|
||||
_ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_is_system ON local_projects(is_system)`).Error
|
||||
_ = db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code_variant ON local_projects(code, variant)`).Error
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasSettings returns true if connection settings exist
|
||||
func (l *LocalDB) HasSettings() bool {
|
||||
var count int64
|
||||
@@ -236,7 +341,7 @@ func (l *LocalDB) GetProjectByName(ownerUsername, name string) (*LocalProject, e
|
||||
func (l *LocalDB) GetProjectConfigurations(projectUUID string) ([]LocalConfiguration, error) {
|
||||
var configs []LocalConfiguration
|
||||
err := l.db.Where("project_uuid = ? AND is_active = ?", projectUUID, true).
|
||||
Order("created_at DESC").
|
||||
Order(configurationLineOrderClause()).
|
||||
Find(&configs).Error
|
||||
return configs, err
|
||||
}
|
||||
@@ -258,7 +363,8 @@ func (l *LocalDB) EnsureDefaultProject(ownerUsername string) (*LocalProject, err
|
||||
project = &LocalProject{
|
||||
UUID: uuidpkg.NewString(),
|
||||
OwnerUsername: "",
|
||||
Name: "Без проекта",
|
||||
Code: "Без проекта",
|
||||
Name: ptrString("Без проекта"),
|
||||
IsActive: true,
|
||||
IsSystem: true,
|
||||
CreatedAt: now,
|
||||
@@ -286,7 +392,8 @@ func (l *LocalDB) ConsolidateSystemProjects() (int64, error) {
|
||||
canonical = LocalProject{
|
||||
UUID: uuidpkg.NewString(),
|
||||
OwnerUsername: "",
|
||||
Name: "Без проекта",
|
||||
Code: "Без проекта",
|
||||
Name: ptrString("Без проекта"),
|
||||
IsActive: true,
|
||||
IsSystem: true,
|
||||
CreatedAt: now,
|
||||
@@ -367,6 +474,10 @@ WHERE (
|
||||
return tx.RowsAffected, tx.Error
|
||||
}
|
||||
|
||||
func ptrString(value string) *string {
|
||||
return &value
|
||||
}
|
||||
|
||||
// BackfillConfigurationProjects ensures every configuration has project_uuid set.
|
||||
// If missing, it assigns system project "Без проекта" for configuration owner.
|
||||
func (l *LocalDB) BackfillConfigurationProjects(defaultOwner string) error {
|
||||
@@ -403,9 +514,54 @@ func (l *LocalDB) BackfillConfigurationProjects(defaultOwner string) error {
|
||||
|
||||
// SaveConfiguration saves a configuration to local SQLite
|
||||
func (l *LocalDB) SaveConfiguration(config *LocalConfiguration) error {
|
||||
if config != nil && config.IsActive && config.Line <= 0 {
|
||||
line, err := l.NextConfigurationLine(config.ProjectUUID, config.UUID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
config.Line = line
|
||||
}
|
||||
return l.db.Save(config).Error
|
||||
}
|
||||
|
||||
func (l *LocalDB) NextConfigurationLine(projectUUID *string, excludeUUID string) (int, error) {
|
||||
return NextConfigurationLineTx(l.db, projectUUID, excludeUUID)
|
||||
}
|
||||
|
||||
func NextConfigurationLineTx(tx *gorm.DB, projectUUID *string, excludeUUID string) (int, error) {
|
||||
query := tx.Model(&LocalConfiguration{}).
|
||||
Where("is_active = ?", true)
|
||||
|
||||
trimmedExclude := strings.TrimSpace(excludeUUID)
|
||||
if trimmedExclude != "" {
|
||||
query = query.Where("uuid <> ?", trimmedExclude)
|
||||
}
|
||||
|
||||
if projectUUID != nil && strings.TrimSpace(*projectUUID) != "" {
|
||||
query = query.Where("project_uuid = ?", strings.TrimSpace(*projectUUID))
|
||||
} else {
|
||||
query = query.Where("project_uuid IS NULL OR TRIM(project_uuid) = ''")
|
||||
}
|
||||
|
||||
var maxLine int
|
||||
if err := query.Select("COALESCE(MAX(line_no), 0)").Scan(&maxLine).Error; err != nil {
|
||||
return 0, fmt.Errorf("read max line_no: %w", err)
|
||||
}
|
||||
if maxLine < 0 {
|
||||
maxLine = 0
|
||||
}
|
||||
|
||||
next := ((maxLine / 10) + 1) * 10
|
||||
if next < 10 {
|
||||
next = 10
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func configurationLineOrderClause() string {
|
||||
return "CASE WHEN COALESCE(local_configurations.line_no, 0) <= 0 THEN 2147483647 ELSE local_configurations.line_no END ASC, local_configurations.created_at DESC, local_configurations.id DESC"
|
||||
}
|
||||
|
||||
// GetConfigurations returns all local configurations
|
||||
func (l *LocalDB) GetConfigurations() ([]LocalConfiguration, error) {
|
||||
var configs []LocalConfiguration
|
||||
@@ -425,18 +581,36 @@ func (l *LocalDB) ListConfigurationsWithFilters(status string, search string, of
|
||||
query := l.db.Model(&LocalConfiguration{})
|
||||
switch status {
|
||||
case "active":
|
||||
query = query.Where("is_active = ?", true)
|
||||
query = query.Where("local_configurations.is_active = ?", true)
|
||||
case "archived":
|
||||
query = query.Where("is_active = ?", false)
|
||||
query = query.Where("local_configurations.is_active = ?", false)
|
||||
case "all", "":
|
||||
// no-op
|
||||
default:
|
||||
query = query.Where("is_active = ?", true)
|
||||
query = query.Where("local_configurations.is_active = ?", true)
|
||||
}
|
||||
|
||||
search = strings.TrimSpace(search)
|
||||
if search != "" {
|
||||
query = query.Where("LOWER(name) LIKE ?", "%"+strings.ToLower(search)+"%")
|
||||
needle := "%" + strings.ToLower(search) + "%"
|
||||
hasProjectsTable := l.db.Migrator().HasTable(&LocalProject{})
|
||||
hasServerModel := l.db.Migrator().HasColumn(&LocalConfiguration{}, "server_model")
|
||||
|
||||
conditions := []string{"LOWER(local_configurations.name) LIKE ?"}
|
||||
args := []interface{}{needle}
|
||||
|
||||
if hasProjectsTable {
|
||||
query = query.Joins("LEFT JOIN local_projects lp ON lp.uuid = local_configurations.project_uuid")
|
||||
conditions = append(conditions, "LOWER(COALESCE(lp.name, '')) LIKE ?")
|
||||
args = append(args, needle)
|
||||
}
|
||||
|
||||
if hasServerModel {
|
||||
conditions = append(conditions, "LOWER(COALESCE(local_configurations.server_model, '')) LIKE ?")
|
||||
args = append(args, needle)
|
||||
}
|
||||
|
||||
query = query.Where(strings.Join(conditions, " OR "), args...)
|
||||
}
|
||||
|
||||
var total int64
|
||||
@@ -445,7 +619,7 @@ func (l *LocalDB) ListConfigurationsWithFilters(status string, search string, of
|
||||
}
|
||||
|
||||
var configs []LocalConfiguration
|
||||
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&configs).Error; err != nil {
|
||||
if err := query.Order("local_configurations.created_at DESC").Offset(offset).Limit(limit).Find(&configs).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return configs, total, nil
|
||||
@@ -563,7 +737,11 @@ 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 {
|
||||
if err := l.db.
|
||||
Where("source = ?", "estimate").
|
||||
Where("EXISTS (SELECT 1 FROM local_pricelist_items WHERE local_pricelist_items.pricelist_id = local_pricelists.id)").
|
||||
Order("created_at DESC, id DESC").
|
||||
First(&pricelist).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pricelist, nil
|
||||
@@ -572,7 +750,11 @@ func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
|
||||
// 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.
|
||||
Where("source = ?", source).
|
||||
Where("EXISTS (SELECT 1 FROM local_pricelist_items WHERE local_pricelist_items.pricelist_id = local_pricelists.id)").
|
||||
Order("created_at DESC, id DESC").
|
||||
First(&pricelist).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pricelist, nil
|
||||
@@ -645,6 +827,17 @@ 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 {
|
||||
@@ -665,6 +858,30 @@ 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
|
||||
@@ -684,6 +901,36 @@ 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).
|
||||
@@ -719,6 +966,47 @@ 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
|
||||
@@ -806,6 +1094,145 @@ func (l *LocalDB) GetPendingCount() int64 {
|
||||
return l.CountPendingChanges()
|
||||
}
|
||||
|
||||
// RepairPendingChanges attempts to fix errored pending changes by validating and correcting data.
|
||||
// Returns the number of changes repaired and a list of errors that couldn't be fixed.
|
||||
func (l *LocalDB) RepairPendingChanges() (int, []string, error) {
|
||||
var erroredChanges []PendingChange
|
||||
if err := l.db.Where("last_error != ?", "").Find(&erroredChanges).Error; err != nil {
|
||||
return 0, nil, fmt.Errorf("fetching errored changes: %w", err)
|
||||
}
|
||||
|
||||
if len(erroredChanges) == 0 {
|
||||
return 0, nil, nil
|
||||
}
|
||||
|
||||
repaired := 0
|
||||
var remainingErrors []string
|
||||
|
||||
for _, change := range erroredChanges {
|
||||
var repairErr error
|
||||
switch change.EntityType {
|
||||
case "project":
|
||||
repairErr = l.repairProjectChange(&change)
|
||||
case "configuration":
|
||||
repairErr = l.repairConfigurationChange(&change)
|
||||
default:
|
||||
repairErr = fmt.Errorf("unknown entity type: %s", change.EntityType)
|
||||
}
|
||||
|
||||
if repairErr != nil {
|
||||
remainingErrors = append(remainingErrors, fmt.Sprintf("%s %s %s: %v",
|
||||
change.Operation, change.EntityType, change.EntityUUID[:8], repairErr))
|
||||
continue
|
||||
}
|
||||
|
||||
// Clear error and reset attempts
|
||||
if err := l.db.Model(&PendingChange{}).Where("id = ?", change.ID).Updates(map[string]interface{}{
|
||||
"last_error": "",
|
||||
"attempts": 0,
|
||||
}).Error; err != nil {
|
||||
remainingErrors = append(remainingErrors, fmt.Sprintf("clearing error for %s: %v", change.EntityUUID[:8], err))
|
||||
continue
|
||||
}
|
||||
|
||||
repaired++
|
||||
}
|
||||
|
||||
return repaired, remainingErrors, nil
|
||||
}
|
||||
|
||||
// repairProjectChange validates and fixes project data.
|
||||
// Note: This only validates local data. Server-side conflicts (like duplicate code+variant)
|
||||
// are handled by sync service layer with deduplication logic.
|
||||
func (l *LocalDB) repairProjectChange(change *PendingChange) error {
|
||||
project, err := l.GetProjectByUUID(change.EntityUUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("project not found locally: %w", err)
|
||||
}
|
||||
|
||||
modified := false
|
||||
|
||||
// Fix Code: must be non-empty
|
||||
if strings.TrimSpace(project.Code) == "" {
|
||||
if project.Name != nil && strings.TrimSpace(*project.Name) != "" {
|
||||
project.Code = strings.TrimSpace(*project.Name)
|
||||
} else {
|
||||
project.Code = project.UUID[:8]
|
||||
}
|
||||
modified = true
|
||||
}
|
||||
|
||||
// Fix Name: use Code if empty
|
||||
if project.Name == nil || strings.TrimSpace(*project.Name) == "" {
|
||||
name := project.Code
|
||||
project.Name = &name
|
||||
modified = true
|
||||
}
|
||||
|
||||
// Fix OwnerUsername: must be non-empty
|
||||
if strings.TrimSpace(project.OwnerUsername) == "" {
|
||||
project.OwnerUsername = l.GetDBUser()
|
||||
if project.OwnerUsername == "" {
|
||||
return fmt.Errorf("cannot determine owner username")
|
||||
}
|
||||
modified = true
|
||||
}
|
||||
|
||||
// Check for local duplicates with same (code, variant)
|
||||
var duplicate LocalProject
|
||||
err = l.db.Where("code = ? AND variant = ? AND uuid != ?", project.Code, project.Variant, project.UUID).
|
||||
First(&duplicate).Error
|
||||
if err == nil {
|
||||
// Found local duplicate - deduplicate by appending UUID suffix to variant
|
||||
if project.Variant == "" {
|
||||
project.Variant = project.UUID[:8]
|
||||
} else {
|
||||
project.Variant = project.Variant + "-" + project.UUID[:8]
|
||||
}
|
||||
modified = true
|
||||
}
|
||||
|
||||
if modified {
|
||||
if err := l.SaveProject(project); err != nil {
|
||||
return fmt.Errorf("saving repaired project: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// repairConfigurationChange validates and fixes configuration data
|
||||
func (l *LocalDB) repairConfigurationChange(change *PendingChange) error {
|
||||
config, err := l.GetConfigurationByUUID(change.EntityUUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("configuration not found locally: %w", err)
|
||||
}
|
||||
|
||||
modified := false
|
||||
|
||||
// Check if referenced project exists
|
||||
if config.ProjectUUID != nil && *config.ProjectUUID != "" {
|
||||
_, err := l.GetProjectByUUID(*config.ProjectUUID)
|
||||
if err != nil {
|
||||
// Project doesn't exist locally - use default system project
|
||||
systemProject, sysErr := l.EnsureDefaultProject(config.OriginalUsername)
|
||||
if sysErr != nil {
|
||||
return fmt.Errorf("getting system project: %w", sysErr)
|
||||
}
|
||||
config.ProjectUUID = &systemProject.UUID
|
||||
modified = true
|
||||
}
|
||||
}
|
||||
|
||||
if modified {
|
||||
if err := l.SaveConfiguration(config); err != nil {
|
||||
return fmt.Errorf("saving repaired configuration: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRemoteMigrationApplied returns a locally applied remote migration by ID.
|
||||
func (l *LocalDB) GetRemoteMigrationApplied(id string) (*LocalRemoteMigrationApplied, error) {
|
||||
var migration LocalRemoteMigrationApplied
|
||||
|
||||
@@ -51,8 +51,8 @@ func TestRunLocalMigrationsBackfillsDefaultProject(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("get system project: %v", err)
|
||||
}
|
||||
if project.Name != "Без проекта" {
|
||||
t.Fatalf("expected system project name, got %q", project.Name)
|
||||
if project.Name == nil || *project.Name != "Без проекта" {
|
||||
t.Fatalf("expected system project name, got %v", project.Name)
|
||||
}
|
||||
if !project.IsSystem {
|
||||
t.Fatalf("expected system project flag")
|
||||
|
||||
@@ -58,6 +58,61 @@ var localMigrations = []localMigration{
|
||||
name: "Backfill source for local pricelists and create source indexes",
|
||||
run: backfillLocalPricelistSource,
|
||||
},
|
||||
{
|
||||
id: "2026_02_09_drop_component_unused_fields",
|
||||
name: "Remove current_price and synced_at from local_components (unused fields)",
|
||||
run: dropComponentUnusedFields,
|
||||
},
|
||||
{
|
||||
id: "2026_02_09_add_warehouse_competitor_pricelists",
|
||||
name: "Add warehouse_pricelist_id and competitor_pricelist_id to local_configurations",
|
||||
run: addWarehouseCompetitorPriceLists,
|
||||
},
|
||||
{
|
||||
id: "2026_02_11_local_pricelist_item_category",
|
||||
name: "Add lot_category to local_pricelist_items and create indexes",
|
||||
run: addLocalPricelistItemCategoryAndIndexes,
|
||||
},
|
||||
{
|
||||
id: "2026_02_11_local_config_article",
|
||||
name: "Add article to local_configurations",
|
||||
run: addLocalConfigurationArticle,
|
||||
},
|
||||
{
|
||||
id: "2026_02_11_local_config_server_model",
|
||||
name: "Add server_model to local_configurations",
|
||||
run: addLocalConfigurationServerModel,
|
||||
},
|
||||
{
|
||||
id: "2026_02_11_local_config_support_code",
|
||||
name: "Add support_code to local_configurations",
|
||||
run: addLocalConfigurationSupportCode,
|
||||
},
|
||||
{
|
||||
id: "2026_02_13_local_project_code",
|
||||
name: "Add project code to local_projects and backfill",
|
||||
run: addLocalProjectCode,
|
||||
},
|
||||
{
|
||||
id: "2026_02_13_local_project_variant",
|
||||
name: "Add project variant to local_projects and backfill",
|
||||
run: addLocalProjectVariant,
|
||||
},
|
||||
{
|
||||
id: "2026_02_13_local_project_name_nullable",
|
||||
name: "Allow NULL project names in local_projects",
|
||||
run: allowLocalProjectNameNull,
|
||||
},
|
||||
{
|
||||
id: "2026_02_19_configuration_versions_dedup_spec_price",
|
||||
name: "Deduplicate configuration revisions by spec+price",
|
||||
run: deduplicateConfigurationVersionsBySpecAndPrice,
|
||||
},
|
||||
{
|
||||
id: "2026_02_19_local_config_line_no",
|
||||
name: "Add line_no to local_configurations and backfill ordering",
|
||||
run: addLocalConfigurationLineNo,
|
||||
},
|
||||
}
|
||||
|
||||
func runLocalMigrations(db *gorm.DB) error {
|
||||
@@ -194,7 +249,8 @@ func ensureDefaultProjectTx(tx *gorm.DB, ownerUsername string) (*LocalProject, e
|
||||
project = LocalProject{
|
||||
UUID: uuid.NewString(),
|
||||
OwnerUsername: ownerUsername,
|
||||
Name: "Без проекта",
|
||||
Code: "Без проекта",
|
||||
Name: ptrString("Без проекта"),
|
||||
IsActive: true,
|
||||
IsSystem: true,
|
||||
CreatedAt: now,
|
||||
@@ -208,6 +264,139 @@ func ensureDefaultProjectTx(tx *gorm.DB, ownerUsername string) (*LocalProject, e
|
||||
return &project, nil
|
||||
}
|
||||
|
||||
func addLocalProjectCode(tx *gorm.DB) error {
|
||||
if err := tx.Exec(`ALTER TABLE local_projects ADD COLUMN code TEXT`).Error; err != nil {
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "duplicate") &&
|
||||
!strings.Contains(strings.ToLower(err.Error()), "exists") {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Drop unique index if it already exists to allow de-duplication updates.
|
||||
if err := tx.Exec(`DROP INDEX IF EXISTS idx_local_projects_code`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Copy code from current project name.
|
||||
if err := tx.Exec(`
|
||||
UPDATE local_projects
|
||||
SET code = TRIM(COALESCE(name, ''))`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Ensure any remaining blanks have a unique fallback.
|
||||
if err := tx.Exec(`
|
||||
UPDATE local_projects
|
||||
SET code = 'P-' || uuid
|
||||
WHERE code IS NULL OR TRIM(code) = ''`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// De-duplicate codes: OPS-1948-2, OPS-1948-3...
|
||||
if err := tx.Exec(`
|
||||
WITH ranked AS (
|
||||
SELECT id, code,
|
||||
ROW_NUMBER() OVER (PARTITION BY code ORDER BY id) AS rn
|
||||
FROM local_projects
|
||||
)
|
||||
UPDATE local_projects
|
||||
SET code = code || '-' || (SELECT rn FROM ranked WHERE ranked.id = local_projects.id)
|
||||
WHERE id IN (SELECT id FROM ranked WHERE rn > 1)`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create unique index for project codes (ignore if exists).
|
||||
if err := tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code ON local_projects(code)`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func addLocalProjectVariant(tx *gorm.DB) error {
|
||||
if err := tx.Exec(`ALTER TABLE local_projects ADD COLUMN variant TEXT NOT NULL DEFAULT ''`).Error; err != nil {
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "duplicate") &&
|
||||
!strings.Contains(strings.ToLower(err.Error()), "exists") {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Drop legacy code index if present.
|
||||
if err := tx.Exec(`DROP INDEX IF EXISTS idx_local_projects_code`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Reset code from name and clear variant.
|
||||
if err := tx.Exec(`
|
||||
UPDATE local_projects
|
||||
SET code = TRIM(COALESCE(name, '')),
|
||||
variant = ''`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// De-duplicate by assigning variant numbers: 2,3...
|
||||
if err := tx.Exec(`
|
||||
WITH ranked AS (
|
||||
SELECT id, code,
|
||||
ROW_NUMBER() OVER (PARTITION BY code ORDER BY id) AS rn
|
||||
FROM local_projects
|
||||
)
|
||||
UPDATE local_projects
|
||||
SET variant = CASE
|
||||
WHEN (SELECT rn FROM ranked WHERE ranked.id = local_projects.id) = 1 THEN ''
|
||||
ELSE '-' || CAST((SELECT rn FROM ranked WHERE ranked.id = local_projects.id) AS TEXT)
|
||||
END`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code_variant ON local_projects(code, variant)`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func allowLocalProjectNameNull(tx *gorm.DB) error {
|
||||
if err := tx.Exec(`ALTER TABLE local_projects RENAME TO local_projects_old`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
CREATE TABLE local_projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
server_id INTEGER NULL,
|
||||
owner_username TEXT NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
variant TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NULL,
|
||||
tracker_url TEXT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
is_system INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME,
|
||||
synced_at DATETIME NULL,
|
||||
sync_status TEXT DEFAULT 'local'
|
||||
)`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = tx.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_owner_username ON local_projects(owner_username)`).Error
|
||||
_ = tx.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_is_active ON local_projects(is_active)`).Error
|
||||
_ = tx.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_is_system ON local_projects(is_system)`).Error
|
||||
_ = tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code_variant ON local_projects(code, variant)`).Error
|
||||
|
||||
if err := tx.Exec(`
|
||||
INSERT INTO local_projects (id, uuid, server_id, owner_username, code, variant, name, tracker_url, is_active, is_system, created_at, updated_at, synced_at, sync_status)
|
||||
SELECT id, uuid, server_id, owner_username, code, variant, name, tracker_url, is_active, is_system, created_at, updated_at, synced_at, sync_status
|
||||
FROM local_projects_old`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = tx.Exec(`DROP TABLE local_projects_old`).Error
|
||||
return nil
|
||||
}
|
||||
|
||||
func backfillConfigurationPricelists(tx *gorm.DB) error {
|
||||
var latest LocalPricelist
|
||||
if err := tx.Where("source = ?", "estimate").Order("created_at DESC").First(&latest).Error; err != nil {
|
||||
@@ -249,6 +438,93 @@ func chooseNonZeroTime(candidate time.Time, fallback time.Time) time.Time {
|
||||
return candidate
|
||||
}
|
||||
|
||||
func deduplicateConfigurationVersionsBySpecAndPrice(tx *gorm.DB) error {
|
||||
var configs []LocalConfiguration
|
||||
if err := tx.Select("uuid", "current_version_id").Find(&configs).Error; err != nil {
|
||||
return fmt.Errorf("load configurations for revision deduplication: %w", err)
|
||||
}
|
||||
|
||||
var removedTotal int
|
||||
for i := range configs {
|
||||
cfg := configs[i]
|
||||
|
||||
var versions []LocalConfigurationVersion
|
||||
if err := tx.Where("configuration_uuid = ?", cfg.UUID).
|
||||
Order("version_no ASC, created_at ASC").
|
||||
Find(&versions).Error; err != nil {
|
||||
return fmt.Errorf("load versions for %s: %w", cfg.UUID, err)
|
||||
}
|
||||
if len(versions) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
deleteIDs := make([]string, 0)
|
||||
deleteSet := make(map[string]struct{})
|
||||
kept := make([]LocalConfigurationVersion, 0, len(versions))
|
||||
var prevKey string
|
||||
hasPrev := false
|
||||
|
||||
for _, version := range versions {
|
||||
snapshotCfg, err := DecodeConfigurationSnapshot(version.Data)
|
||||
if err != nil {
|
||||
// Keep malformed snapshots untouched and reset chain to avoid accidental removals.
|
||||
kept = append(kept, version)
|
||||
hasPrev = false
|
||||
continue
|
||||
}
|
||||
|
||||
key, err := BuildConfigurationSpecPriceFingerprint(snapshotCfg)
|
||||
if err != nil {
|
||||
kept = append(kept, version)
|
||||
hasPrev = false
|
||||
continue
|
||||
}
|
||||
|
||||
if !hasPrev || key != prevKey {
|
||||
kept = append(kept, version)
|
||||
prevKey = key
|
||||
hasPrev = true
|
||||
continue
|
||||
}
|
||||
|
||||
deleteIDs = append(deleteIDs, version.ID)
|
||||
deleteSet[version.ID] = struct{}{}
|
||||
}
|
||||
|
||||
if len(deleteIDs) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := tx.Where("id IN ?", deleteIDs).Delete(&LocalConfigurationVersion{}).Error; err != nil {
|
||||
return fmt.Errorf("delete duplicate versions for %s: %w", cfg.UUID, err)
|
||||
}
|
||||
removedTotal += len(deleteIDs)
|
||||
|
||||
latestKeptID := kept[len(kept)-1].ID
|
||||
if cfg.CurrentVersionID == nil || *cfg.CurrentVersionID == "" {
|
||||
if err := tx.Model(&LocalConfiguration{}).
|
||||
Where("uuid = ?", cfg.UUID).
|
||||
Update("current_version_id", latestKeptID).Error; err != nil {
|
||||
return fmt.Errorf("set missing current_version_id for %s: %w", cfg.UUID, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if _, deleted := deleteSet[*cfg.CurrentVersionID]; deleted {
|
||||
if err := tx.Model(&LocalConfiguration{}).
|
||||
Where("uuid = ?", cfg.UUID).
|
||||
Update("current_version_id", latestKeptID).Error; err != nil {
|
||||
return fmt.Errorf("repair current_version_id for %s: %w", cfg.UUID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if removedTotal > 0 {
|
||||
slog.Info("deduplicated configuration revisions", "removed_versions", removedTotal)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fixLocalPricelistIndexes(tx *gorm.DB) error {
|
||||
type indexRow struct {
|
||||
Name string `gorm:"column:name"`
|
||||
@@ -316,3 +592,275 @@ func backfillLocalPricelistSource(tx *gorm.DB) error {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func addLocalConfigurationLineNo(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 ('line_no')
|
||||
`).Scan(&columns).Error; err != nil {
|
||||
return fmt.Errorf("check local_configurations(line_no) existence: %w", err)
|
||||
}
|
||||
if len(columns) == 0 {
|
||||
if err := tx.Exec(`
|
||||
ALTER TABLE local_configurations
|
||||
ADD COLUMN line_no INTEGER
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("add local_configurations.line_no: %w", err)
|
||||
}
|
||||
slog.Info("added line_no to local_configurations")
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY COALESCE(NULLIF(TRIM(project_uuid), ''), '__NO_PROJECT__')
|
||||
ORDER BY created_at ASC, id ASC
|
||||
) AS rn
|
||||
FROM local_configurations
|
||||
WHERE line_no IS NULL OR line_no <= 0
|
||||
)
|
||||
UPDATE local_configurations
|
||||
SET line_no = (
|
||||
SELECT rn * 10
|
||||
FROM ranked
|
||||
WHERE ranked.id = local_configurations.id
|
||||
)
|
||||
WHERE id IN (SELECT id FROM ranked)
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("backfill local_configurations.line_no: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_local_configurations_project_line_no
|
||||
ON local_configurations(project_uuid, line_no)
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("ensure idx_local_configurations_project_line_no: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -83,30 +83,36 @@ func (s *LocalStringList) Scan(value interface{}) error {
|
||||
|
||||
// LocalConfiguration stores configurations in local SQLite
|
||||
type LocalConfiguration struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UUID string `gorm:"uniqueIndex;not null" json:"uuid"`
|
||||
ServerID *uint `json:"server_id"` // ID on MariaDB server, NULL if local only
|
||||
ProjectUUID *string `gorm:"index" json:"project_uuid,omitempty"`
|
||||
CurrentVersionID *string `gorm:"index" json:"current_version_id,omitempty"`
|
||||
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Items LocalConfigItems `gorm:"type:text" json:"items"` // JSON stored as text in SQLite
|
||||
TotalPrice *float64 `json:"total_price"`
|
||||
CustomPrice *float64 `json:"custom_price"`
|
||||
Notes string `json:"notes"`
|
||||
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
||||
ServerCount int `gorm:"default:1" json:"server_count"`
|
||||
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
|
||||
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
|
||||
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
SyncedAt *time.Time `json:"synced_at"`
|
||||
SyncStatus string `gorm:"default:'local'" json:"sync_status"` // 'local', 'synced', 'modified'
|
||||
OriginalUserID uint `json:"original_user_id"` // UserID from MariaDB for reference
|
||||
OriginalUsername string `gorm:"not null;default:'';index" json:"original_username"`
|
||||
CurrentVersion *LocalConfigurationVersion `gorm:"foreignKey:CurrentVersionID;references:ID" json:"current_version,omitempty"`
|
||||
Versions []LocalConfigurationVersion `gorm:"foreignKey:ConfigurationUUID;references:UUID" json:"versions,omitempty"`
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UUID string `gorm:"uniqueIndex;not null" json:"uuid"`
|
||||
ServerID *uint `json:"server_id"` // ID on MariaDB server, NULL if local only
|
||||
ProjectUUID *string `gorm:"index" json:"project_uuid,omitempty"`
|
||||
CurrentVersionID *string `gorm:"index" json:"current_version_id,omitempty"`
|
||||
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Items LocalConfigItems `gorm:"type:text" json:"items"` // JSON stored as text in SQLite
|
||||
TotalPrice *float64 `json:"total_price"`
|
||||
CustomPrice *float64 `json:"custom_price"`
|
||||
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"`
|
||||
Line int `gorm:"column:line_no;index" json:"line"`
|
||||
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
SyncedAt *time.Time `json:"synced_at"`
|
||||
SyncStatus string `gorm:"default:'local'" json:"sync_status"` // 'local', 'synced', 'modified'
|
||||
OriginalUserID uint `json:"original_user_id"` // UserID from MariaDB for reference
|
||||
OriginalUsername string `gorm:"not null;default:'';index" json:"original_username"`
|
||||
CurrentVersion *LocalConfigurationVersion `gorm:"foreignKey:CurrentVersionID;references:ID" json:"current_version,omitempty"`
|
||||
Versions []LocalConfigurationVersion `gorm:"foreignKey:ConfigurationUUID;references:UUID" json:"versions,omitempty"`
|
||||
}
|
||||
|
||||
func (LocalConfiguration) TableName() string {
|
||||
@@ -118,7 +124,9 @@ type LocalProject struct {
|
||||
UUID string `gorm:"uniqueIndex;not null" json:"uuid"`
|
||||
ServerID *uint `json:"server_id,omitempty"`
|
||||
OwnerUsername string `gorm:"not null;index" json:"owner_username"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Code string `gorm:"not null;index:idx_local_projects_code_variant,priority:1" json:"code"`
|
||||
Variant string `gorm:"default:'';index:idx_local_projects_code_variant,priority:2" json:"variant"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
TrackerURL string `json:"tracker_url"`
|
||||
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
||||
IsSystem bool `gorm:"default:false;index" json:"is_system"`
|
||||
@@ -170,6 +178,7 @@ 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"`
|
||||
@@ -179,14 +188,13 @@ func (LocalPricelistItem) TableName() string {
|
||||
return "local_pricelist_items"
|
||||
}
|
||||
|
||||
// LocalComponent stores cached components for offline search
|
||||
// LocalComponent stores cached components for offline search (metadata only)
|
||||
// All pricing is now sourced from local_pricelist_items based on configuration pricelist selection
|
||||
type LocalComponent struct {
|
||||
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"`
|
||||
LotName string `gorm:"primaryKey" json:"lot_name"`
|
||||
LotDescription string `json:"lot_description"`
|
||||
Category string `json:"category"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
func (LocalComponent) TableName() string {
|
||||
|
||||
128
internal/localdb/pricelist_latest_test.go
Normal file
128
internal/localdb/pricelist_latest_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package localdb
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGetLatestLocalPricelistBySource_SkipsPricelistWithoutItems(t *testing.T) {
|
||||
local, err := New(filepath.Join(t.TempDir(), "latest_without_items.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open localdb: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
base := time.Now().Add(-time.Minute)
|
||||
withItems := &LocalPricelist{
|
||||
ServerID: 1001,
|
||||
Source: "estimate",
|
||||
Version: "E-1",
|
||||
Name: "with-items",
|
||||
CreatedAt: base,
|
||||
SyncedAt: base,
|
||||
}
|
||||
if err := local.SaveLocalPricelist(withItems); err != nil {
|
||||
t.Fatalf("save pricelist with items: %v", err)
|
||||
}
|
||||
storedWithItems, err := local.GetLocalPricelistByServerID(withItems.ServerID)
|
||||
if err != nil {
|
||||
t.Fatalf("load pricelist with items: %v", err)
|
||||
}
|
||||
if err := local.SaveLocalPricelistItems([]LocalPricelistItem{
|
||||
{
|
||||
PricelistID: storedWithItems.ID,
|
||||
LotName: "CPU_A",
|
||||
Price: 100,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save pricelist items: %v", err)
|
||||
}
|
||||
|
||||
withoutItems := &LocalPricelist{
|
||||
ServerID: 1002,
|
||||
Source: "estimate",
|
||||
Version: "E-2",
|
||||
Name: "without-items",
|
||||
CreatedAt: base.Add(2 * time.Second),
|
||||
SyncedAt: base.Add(2 * time.Second),
|
||||
}
|
||||
if err := local.SaveLocalPricelist(withoutItems); err != nil {
|
||||
t.Fatalf("save pricelist without items: %v", err)
|
||||
}
|
||||
|
||||
got, err := local.GetLatestLocalPricelistBySource("estimate")
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestLocalPricelistBySource: %v", err)
|
||||
}
|
||||
if got.ServerID != withItems.ServerID {
|
||||
t.Fatalf("expected server_id=%d, got %d", withItems.ServerID, got.ServerID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestLocalPricelistBySource_TieBreaksByID(t *testing.T) {
|
||||
local, err := New(filepath.Join(t.TempDir(), "latest_tie_break.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open localdb: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
base := time.Now().Add(-time.Minute)
|
||||
first := &LocalPricelist{
|
||||
ServerID: 2001,
|
||||
Source: "warehouse",
|
||||
Version: "S-1",
|
||||
Name: "first",
|
||||
CreatedAt: base,
|
||||
SyncedAt: base,
|
||||
}
|
||||
if err := local.SaveLocalPricelist(first); err != nil {
|
||||
t.Fatalf("save first pricelist: %v", err)
|
||||
}
|
||||
storedFirst, err := local.GetLocalPricelistByServerID(first.ServerID)
|
||||
if err != nil {
|
||||
t.Fatalf("load first pricelist: %v", err)
|
||||
}
|
||||
if err := local.SaveLocalPricelistItems([]LocalPricelistItem{
|
||||
{
|
||||
PricelistID: storedFirst.ID,
|
||||
LotName: "CPU_A",
|
||||
Price: 101,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save first items: %v", err)
|
||||
}
|
||||
|
||||
second := &LocalPricelist{
|
||||
ServerID: 2002,
|
||||
Source: "warehouse",
|
||||
Version: "S-2",
|
||||
Name: "second",
|
||||
CreatedAt: base,
|
||||
SyncedAt: base,
|
||||
}
|
||||
if err := local.SaveLocalPricelist(second); err != nil {
|
||||
t.Fatalf("save second pricelist: %v", err)
|
||||
}
|
||||
storedSecond, err := local.GetLocalPricelistByServerID(second.ServerID)
|
||||
if err != nil {
|
||||
t.Fatalf("load second pricelist: %v", err)
|
||||
}
|
||||
if err := local.SaveLocalPricelistItems([]LocalPricelistItem{
|
||||
{
|
||||
PricelistID: storedSecond.ID,
|
||||
LotName: "CPU_A",
|
||||
Price: 102,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save second items: %v", err)
|
||||
}
|
||||
|
||||
got, err := local.GetLatestLocalPricelistBySource("warehouse")
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestLocalPricelistBySource: %v", err)
|
||||
}
|
||||
if got.ServerID != second.ServerID {
|
||||
t.Fatalf("expected server_id=%d, got %d", second.ServerID, got.ServerID)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package localdb
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -22,8 +23,12 @@ 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,
|
||||
"line": localCfg.Line,
|
||||
"price_updated_at": localCfg.PriceUpdatedAt,
|
||||
"created_at": localCfg.CreatedAt,
|
||||
"updated_at": localCfg.UpdatedAt,
|
||||
@@ -52,8 +57,12 @@ 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"`
|
||||
Line int `json:"line"`
|
||||
PriceUpdatedAt *time.Time `json:"price_updated_at"`
|
||||
OriginalUserID uint `json:"original_user_id"`
|
||||
OriginalUsername string `json:"original_username"`
|
||||
@@ -78,10 +87,62 @@ 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,
|
||||
Line: snapshot.Line,
|
||||
PriceUpdatedAt: snapshot.PriceUpdatedAt,
|
||||
OriginalUserID: snapshot.OriginalUserID,
|
||||
OriginalUsername: snapshot.OriginalUsername,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type configurationSpecPriceFingerprint struct {
|
||||
Items []configurationSpecPriceFingerprintItem `json:"items"`
|
||||
ServerCount int `json:"server_count"`
|
||||
TotalPrice *float64 `json:"total_price,omitempty"`
|
||||
CustomPrice *float64 `json:"custom_price,omitempty"`
|
||||
}
|
||||
|
||||
type configurationSpecPriceFingerprintItem struct {
|
||||
LotName string `json:"lot_name"`
|
||||
Quantity int `json:"quantity"`
|
||||
UnitPrice float64 `json:"unit_price"`
|
||||
}
|
||||
|
||||
// BuildConfigurationSpecPriceFingerprint returns a stable JSON key based on
|
||||
// spec + price fields only, used for revision deduplication.
|
||||
func BuildConfigurationSpecPriceFingerprint(localCfg *LocalConfiguration) (string, error) {
|
||||
items := make([]configurationSpecPriceFingerprintItem, 0, len(localCfg.Items))
|
||||
for _, item := range localCfg.Items {
|
||||
items = append(items, configurationSpecPriceFingerprintItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: item.UnitPrice,
|
||||
})
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].LotName != items[j].LotName {
|
||||
return items[i].LotName < items[j].LotName
|
||||
}
|
||||
if items[i].Quantity != items[j].Quantity {
|
||||
return items[i].Quantity < items[j].Quantity
|
||||
}
|
||||
return items[i].UnitPrice < items[j].UnitPrice
|
||||
})
|
||||
|
||||
payload := configurationSpecPriceFingerprint{
|
||||
Items: items,
|
||||
ServerCount: localCfg.ServerCount,
|
||||
TotalPrice: localCfg.TotalPrice,
|
||||
CustomPrice: localCfg.CustomPrice,
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal spec+price fingerprint: %w", err)
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
@@ -1,22 +1,55 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func CORS() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
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")
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -53,13 +53,18 @@ type Configuration struct {
|
||||
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"`
|
||||
Line int `gorm:"column:line_no;index" json:"line"`
|
||||
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
CurrentVersionNo int `gorm:"-" json:"current_version_no,omitempty"`
|
||||
|
||||
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ 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"`
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ type Project struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
|
||||
OwnerUsername string `gorm:"size:100;not null;index" json:"owner_username"`
|
||||
Name string `gorm:"size:200;not null" json:"name"`
|
||||
Code string `gorm:"size:100;not null;index:idx_qt_projects_code_variant,priority:1" json:"code"`
|
||||
Variant string `gorm:"size:100;not null;default:'';index:idx_qt_projects_code_variant,priority:2" json:"variant"`
|
||||
Name *string `gorm:"size:200" json:"name,omitempty"`
|
||||
TrackerURL string `gorm:"size:500" json:"tracker_url"`
|
||||
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
||||
IsSystem bool `gorm:"default:false;index" json:"is_system"`
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -14,7 +16,13 @@ func NewConfigurationRepository(db *gorm.DB) *ConfigurationRepository {
|
||||
}
|
||||
|
||||
func (r *ConfigurationRepository) Create(config *models.Configuration) error {
|
||||
return r.db.Create(config).Error
|
||||
if err := r.db.Create(config).Error; err != nil {
|
||||
if isUnknownLineNoColumnError(err) {
|
||||
return r.db.Omit("line_no").Create(config).Error
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ConfigurationRepository) GetByID(id uint) (*models.Configuration, error) {
|
||||
@@ -36,7 +44,21 @@ func (r *ConfigurationRepository) GetByUUID(uuid string) (*models.Configuration,
|
||||
}
|
||||
|
||||
func (r *ConfigurationRepository) Update(config *models.Configuration) error {
|
||||
return r.db.Save(config).Error
|
||||
if err := r.db.Save(config).Error; err != nil {
|
||||
if isUnknownLineNoColumnError(err) {
|
||||
return r.db.Omit("line_no").Save(config).Error
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isUnknownLineNoColumnError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "unknown column 'line_no'") || strings.Contains(msg, "no column named line_no")
|
||||
}
|
||||
|
||||
func (r *ConfigurationRepository) Delete(id uint) error {
|
||||
|
||||
@@ -3,10 +3,13 @@ package repository
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/lotmatch"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -38,7 +41,7 @@ func (r *PricelistRepository) ListBySource(source string, offset, limit int) ([]
|
||||
}
|
||||
|
||||
var pricelists []models.Pricelist
|
||||
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
|
||||
if err := query.Order("created_at DESC, id DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("listing pricelists: %w", err)
|
||||
}
|
||||
|
||||
@@ -65,7 +68,7 @@ func (r *PricelistRepository) ListActiveBySource(source string, offset, limit in
|
||||
}
|
||||
|
||||
var pricelists []models.Pricelist
|
||||
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
|
||||
if err := query.Order("created_at DESC, id DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("listing active pricelists: %w", err)
|
||||
}
|
||||
|
||||
@@ -146,7 +149,11 @@ func (r *PricelistRepository) GetLatestActive() (*models.Pricelist, error) {
|
||||
// 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 = ? AND source = ?", true, source).
|
||||
Where("EXISTS (SELECT 1 FROM qt_pricelist_items WHERE qt_pricelist_items.pricelist_id = qt_pricelists.id)").
|
||||
Order("created_at DESC, id DESC").
|
||||
First(&pricelist).Error; err != nil {
|
||||
return nil, fmt.Errorf("getting latest pricelist: %w", err)
|
||||
}
|
||||
return &pricelist, nil
|
||||
@@ -236,16 +243,97 @@ 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
|
||||
}
|
||||
// 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]
|
||||
}
|
||||
items[i].Category = strings.TrimSpace(items[i].LotCategory)
|
||||
}
|
||||
|
||||
// Stock/partnumber enrichment is optional for pricelist item listing.
|
||||
// Return base pricelist rows (lot_name/price/category) even when DB user lacks
|
||||
// access to stock mapping tables (e.g. lot_partnumbers, stock_log).
|
||||
if err := r.enrichItemsWithStock(items); err != nil {
|
||||
slog.Warn("pricelist items stock enrichment skipped", "pricelist_id", pricelistID, "error", err)
|
||||
}
|
||||
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -126,6 +126,101 @@ func TestGetItems_WarehouseAvailableQtyUsesPrefixResolver(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestActiveBySource_SkipsPricelistsWithoutItems(t *testing.T) {
|
||||
repo := newTestPricelistRepository(t)
|
||||
db := repo.db
|
||||
ts := time.Now().Add(-time.Minute)
|
||||
source := "test-estimate-skip-empty"
|
||||
|
||||
emptyLatest := models.Pricelist{
|
||||
Source: source,
|
||||
Version: "E-empty",
|
||||
CreatedBy: "test",
|
||||
IsActive: true,
|
||||
CreatedAt: ts.Add(2 * time.Second),
|
||||
}
|
||||
if err := db.Create(&emptyLatest).Error; err != nil {
|
||||
t.Fatalf("create empty pricelist: %v", err)
|
||||
}
|
||||
|
||||
withItems := models.Pricelist{
|
||||
Source: source,
|
||||
Version: "E-with-items",
|
||||
CreatedBy: "test",
|
||||
IsActive: true,
|
||||
CreatedAt: ts,
|
||||
}
|
||||
if err := db.Create(&withItems).Error; err != nil {
|
||||
t.Fatalf("create pricelist with items: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.PricelistItem{
|
||||
PricelistID: withItems.ID,
|
||||
LotName: "CPU_A",
|
||||
Price: 100,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create pricelist item: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetLatestActiveBySource(source)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestActiveBySource: %v", err)
|
||||
}
|
||||
if got.ID != withItems.ID {
|
||||
t.Fatalf("expected pricelist with items id=%d, got id=%d", withItems.ID, got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestActiveBySource_TieBreaksByID(t *testing.T) {
|
||||
repo := newTestPricelistRepository(t)
|
||||
db := repo.db
|
||||
ts := time.Now().Add(-time.Minute)
|
||||
source := "test-warehouse-tie-break"
|
||||
|
||||
first := models.Pricelist{
|
||||
Source: source,
|
||||
Version: "S-1",
|
||||
CreatedBy: "test",
|
||||
IsActive: true,
|
||||
CreatedAt: ts,
|
||||
}
|
||||
if err := db.Create(&first).Error; err != nil {
|
||||
t.Fatalf("create first pricelist: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.PricelistItem{
|
||||
PricelistID: first.ID,
|
||||
LotName: "CPU_A",
|
||||
Price: 101,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create first item: %v", err)
|
||||
}
|
||||
|
||||
second := models.Pricelist{
|
||||
Source: source,
|
||||
Version: "S-2",
|
||||
CreatedBy: "test",
|
||||
IsActive: true,
|
||||
CreatedAt: ts,
|
||||
}
|
||||
if err := db.Create(&second).Error; err != nil {
|
||||
t.Fatalf("create second pricelist: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.PricelistItem{
|
||||
PricelistID: second.ID,
|
||||
LotName: "CPU_A",
|
||||
Price: 102,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create second item: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetLatestActiveBySource(source)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestActiveBySource: %v", err)
|
||||
}
|
||||
if got.ID != second.ID {
|
||||
t.Fatalf("expected later inserted pricelist id=%d, got id=%d", second.ID, got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestPricelistRepository(t *testing.T) *PricelistRepository {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ func (r *ProjectRepository) UpsertByUUID(project *models.Project) error {
|
||||
Columns: []clause.Column{{Name: "uuid"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"owner_username",
|
||||
"code",
|
||||
"variant",
|
||||
"name",
|
||||
"tracker_url",
|
||||
"is_active",
|
||||
|
||||
@@ -83,10 +83,6 @@ 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)
|
||||
|
||||
@@ -96,8 +92,6 @@ 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:
|
||||
@@ -112,9 +106,8 @@ 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,
|
||||
CurrentPrice: comp.CurrentPrice,
|
||||
LotName: comp.LotName,
|
||||
Model: comp.Model,
|
||||
Lot: &models.Lot{
|
||||
LotName: comp.LotName,
|
||||
LotDescription: comp.LotDescription,
|
||||
@@ -138,9 +131,8 @@ func (r *UnifiedRepo) GetComponent(lotName string) (*models.LotMetadata, error)
|
||||
}
|
||||
|
||||
return &models.LotMetadata{
|
||||
LotName: comp.LotName,
|
||||
Model: comp.Model,
|
||||
CurrentPrice: comp.CurrentPrice,
|
||||
LotName: comp.LotName,
|
||||
Model: comp.Model,
|
||||
Lot: &models.Lot{
|
||||
LotName: comp.LotName,
|
||||
LotDescription: comp.LotDescription,
|
||||
|
||||
@@ -53,7 +53,6 @@ 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"`
|
||||
@@ -92,7 +91,6 @@ 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,
|
||||
@@ -134,7 +132,6 @@ 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,
|
||||
|
||||
@@ -52,10 +52,20 @@ 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) {
|
||||
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
|
||||
if err != nil {
|
||||
@@ -84,6 +94,9 @@ 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,
|
||||
}
|
||||
@@ -146,6 +159,9 @@ 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
|
||||
|
||||
|
||||
@@ -4,33 +4,33 @@ import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
)
|
||||
|
||||
type ExportService struct {
|
||||
config config.ExportConfig
|
||||
config config.ExportConfig
|
||||
categoryRepo *repository.CategoryRepository
|
||||
localDB *localdb.LocalDB
|
||||
}
|
||||
|
||||
func NewExportService(cfg config.ExportConfig, categoryRepo *repository.CategoryRepository) *ExportService {
|
||||
func NewExportService(cfg config.ExportConfig, categoryRepo *repository.CategoryRepository, local *localdb.LocalDB) *ExportService {
|
||||
return &ExportService{
|
||||
config: cfg,
|
||||
categoryRepo: categoryRepo,
|
||||
localDB: local,
|
||||
}
|
||||
}
|
||||
|
||||
type ExportData struct {
|
||||
Name string
|
||||
Items []ExportItem
|
||||
Total float64
|
||||
Notes string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// ExportItem represents a single component in an export block.
|
||||
type ExportItem struct {
|
||||
LotName string
|
||||
Description string
|
||||
@@ -40,14 +40,46 @@ type ExportItem struct {
|
||||
TotalPrice float64
|
||||
}
|
||||
|
||||
func (s *ExportService) ToCSV(data *ExportData) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
w := csv.NewWriter(&buf)
|
||||
// ConfigExportBlock represents one configuration (server) in the export.
|
||||
type ConfigExportBlock struct {
|
||||
Article string
|
||||
Line int
|
||||
ServerCount int
|
||||
UnitPrice float64 // sum of component prices for one server
|
||||
Items []ExportItem
|
||||
}
|
||||
|
||||
// ProjectExportData holds all configuration blocks for a project-level export.
|
||||
type ProjectExportData struct {
|
||||
Configs []ConfigExportBlock
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// ToCSV writes project export data in the new structured CSV format.
|
||||
//
|
||||
// Format:
|
||||
//
|
||||
// Line;Type;p/n;Description;Qty (1 pcs.);Qty (total);Price (1 pcs.);Price (total)
|
||||
// 10;;DL380-ARTICLE;;;10;10470;104 700
|
||||
// ;;MB_INTEL_...;;1;;2074,5;
|
||||
// ...
|
||||
// (empty row)
|
||||
// 20;;DL380-ARTICLE-2;;;2;10470;20 940
|
||||
// ...
|
||||
func (s *ExportService) ToCSV(w io.Writer, data *ProjectExportData) error {
|
||||
// Write UTF-8 BOM for Excel compatibility
|
||||
if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
|
||||
return fmt.Errorf("failed to write BOM: %w", err)
|
||||
}
|
||||
|
||||
csvWriter := csv.NewWriter(w)
|
||||
csvWriter.Comma = ';'
|
||||
defer csvWriter.Flush()
|
||||
|
||||
// Header
|
||||
headers := []string{"Артикул", "Описание", "Категория", "Количество", "Цена за единицу", "Сумма"}
|
||||
if err := w.Write(headers); err != nil {
|
||||
return nil, err
|
||||
headers := []string{"Line", "Type", "p/n", "Description", "Qty (1 pcs.)", "Qty (total)", "Price (1 pcs.)", "Price (total)"}
|
||||
if err := csvWriter.Write(headers); err != nil {
|
||||
return fmt.Errorf("failed to write header: %w", err)
|
||||
}
|
||||
|
||||
// Get category hierarchy for sorting
|
||||
@@ -61,87 +93,260 @@ func (s *ExportService) ToCSV(data *ExportData) ([]byte, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sort items by category display order
|
||||
sortedItems := make([]ExportItem, len(data.Items))
|
||||
copy(sortedItems, data.Items)
|
||||
for i, block := range data.Configs {
|
||||
lineNo := block.Line
|
||||
if lineNo <= 0 {
|
||||
lineNo = (i + 1) * 10
|
||||
}
|
||||
|
||||
// Sort using category display order (items without category go to the end)
|
||||
for i := 0; i < len(sortedItems)-1; i++ {
|
||||
for j := i + 1; j < len(sortedItems); j++ {
|
||||
orderI, hasI := categoryOrder[sortedItems[i].Category]
|
||||
orderJ, hasJ := categoryOrder[sortedItems[j].Category]
|
||||
serverCount := block.ServerCount
|
||||
if serverCount < 1 {
|
||||
serverCount = 1
|
||||
}
|
||||
|
||||
// Items without category go to the end
|
||||
if !hasI && hasJ {
|
||||
sortedItems[i], sortedItems[j] = sortedItems[j], sortedItems[i]
|
||||
} else if hasI && hasJ {
|
||||
// Both have categories, sort by display order
|
||||
if orderI > orderJ {
|
||||
sortedItems[i], sortedItems[j] = sortedItems[j], sortedItems[i]
|
||||
totalPrice := block.UnitPrice * float64(serverCount)
|
||||
|
||||
// Server summary row
|
||||
serverRow := []string{
|
||||
fmt.Sprintf("%d", lineNo), // Line
|
||||
"", // Type
|
||||
block.Article, // p/n
|
||||
"", // Description
|
||||
"", // Qty (1 pcs.)
|
||||
fmt.Sprintf("%d", serverCount), // Qty (total)
|
||||
formatPriceInt(block.UnitPrice), // Price (1 pcs.)
|
||||
formatPriceWithSpace(totalPrice), // Price (total)
|
||||
}
|
||||
if err := csvWriter.Write(serverRow); err != nil {
|
||||
return fmt.Errorf("failed to write server row: %w", err)
|
||||
}
|
||||
|
||||
// Sort items by category display order
|
||||
sortedItems := make([]ExportItem, len(block.Items))
|
||||
copy(sortedItems, block.Items)
|
||||
sortItemsByCategory(sortedItems, categoryOrder)
|
||||
|
||||
// Component rows
|
||||
for _, item := range sortedItems {
|
||||
componentRow := []string{
|
||||
"", // Line
|
||||
item.Category, // Type
|
||||
item.LotName, // p/n
|
||||
"", // Description
|
||||
fmt.Sprintf("%d", item.Quantity), // Qty (1 pcs.)
|
||||
"", // Qty (total)
|
||||
formatPriceComma(item.UnitPrice), // Price (1 pcs.)
|
||||
"", // Price (total)
|
||||
}
|
||||
if err := csvWriter.Write(componentRow); err != nil {
|
||||
return fmt.Errorf("failed to write component row: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Empty separator row between blocks (skip after last)
|
||||
if i < len(data.Configs)-1 {
|
||||
if err := csvWriter.Write([]string{"", "", "", "", "", "", "", ""}); err != nil {
|
||||
return fmt.Errorf("failed to write separator row: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
csvWriter.Flush()
|
||||
if err := csvWriter.Error(); err != nil {
|
||||
return fmt.Errorf("csv writer error: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToCSVBytes is a backward-compatible wrapper that returns CSV data as bytes.
|
||||
func (s *ExportService) ToCSVBytes(data *ProjectExportData) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := s.ToCSV(&buf, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// ConfigToExportData converts a single configuration into ProjectExportData.
|
||||
func (s *ExportService) ConfigToExportData(cfg *models.Configuration) *ProjectExportData {
|
||||
block := s.buildExportBlock(cfg)
|
||||
return &ProjectExportData{
|
||||
Configs: []ConfigExportBlock{block},
|
||||
CreatedAt: cfg.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// ProjectToExportData converts multiple configurations into ProjectExportData.
|
||||
func (s *ExportService) ProjectToExportData(configs []models.Configuration) *ProjectExportData {
|
||||
sortedConfigs := make([]models.Configuration, len(configs))
|
||||
copy(sortedConfigs, configs)
|
||||
sort.Slice(sortedConfigs, func(i, j int) bool {
|
||||
leftLine := sortedConfigs[i].Line
|
||||
rightLine := sortedConfigs[j].Line
|
||||
|
||||
if leftLine <= 0 {
|
||||
leftLine = int(^uint(0) >> 1)
|
||||
}
|
||||
if rightLine <= 0 {
|
||||
rightLine = int(^uint(0) >> 1)
|
||||
}
|
||||
if leftLine != rightLine {
|
||||
return leftLine < rightLine
|
||||
}
|
||||
if !sortedConfigs[i].CreatedAt.Equal(sortedConfigs[j].CreatedAt) {
|
||||
return sortedConfigs[i].CreatedAt.After(sortedConfigs[j].CreatedAt)
|
||||
}
|
||||
return sortedConfigs[i].UUID > sortedConfigs[j].UUID
|
||||
})
|
||||
|
||||
blocks := make([]ConfigExportBlock, 0, len(configs))
|
||||
for i := range sortedConfigs {
|
||||
blocks = append(blocks, s.buildExportBlock(&sortedConfigs[i]))
|
||||
}
|
||||
return &ProjectExportData{
|
||||
Configs: blocks,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ExportService) buildExportBlock(cfg *models.Configuration) ConfigExportBlock {
|
||||
// Batch-fetch categories from local data (pricelist items → local_components fallback)
|
||||
lotNames := make([]string, len(cfg.Items))
|
||||
for i, item := range cfg.Items {
|
||||
lotNames[i] = item.LotName
|
||||
}
|
||||
categories := s.resolveCategories(cfg.PricelistID, lotNames)
|
||||
|
||||
items := make([]ExportItem, len(cfg.Items))
|
||||
var unitTotal float64
|
||||
|
||||
for i, item := range cfg.Items {
|
||||
itemTotal := item.UnitPrice * float64(item.Quantity)
|
||||
items[i] = ExportItem{
|
||||
LotName: item.LotName,
|
||||
Category: categories[item.LotName],
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: item.UnitPrice,
|
||||
TotalPrice: itemTotal,
|
||||
}
|
||||
unitTotal += itemTotal
|
||||
}
|
||||
|
||||
serverCount := cfg.ServerCount
|
||||
if serverCount < 1 {
|
||||
serverCount = 1
|
||||
}
|
||||
|
||||
return ConfigExportBlock{
|
||||
Article: cfg.Article,
|
||||
Line: cfg.Line,
|
||||
ServerCount: serverCount,
|
||||
UnitPrice: unitTotal,
|
||||
Items: items,
|
||||
}
|
||||
}
|
||||
|
||||
// resolveCategories returns lot_name → category map.
|
||||
// Primary source: pricelist items (lot_category). Fallback: local_components table.
|
||||
func (s *ExportService) resolveCategories(pricelistID *uint, lotNames []string) map[string]string {
|
||||
if len(lotNames) == 0 || s.localDB == nil {
|
||||
return map[string]string{}
|
||||
}
|
||||
|
||||
categories := make(map[string]string, len(lotNames))
|
||||
|
||||
// Primary: pricelist items
|
||||
if pricelistID != nil && *pricelistID > 0 {
|
||||
if cats, err := s.localDB.GetLocalLotCategoriesByServerPricelistID(*pricelistID, lotNames); err == nil {
|
||||
for lot, cat := range cats {
|
||||
if strings.TrimSpace(cat) != "" {
|
||||
categories[lot] = cat
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Items
|
||||
for _, item := range sortedItems {
|
||||
row := []string{
|
||||
item.LotName,
|
||||
item.Description,
|
||||
item.Category,
|
||||
fmt.Sprintf("%d", item.Quantity),
|
||||
fmt.Sprintf("%.2f", item.UnitPrice),
|
||||
fmt.Sprintf("%.2f", item.TotalPrice),
|
||||
}
|
||||
if err := w.Write(row); err != nil {
|
||||
return nil, err
|
||||
// Fallback: local_components for any still missing
|
||||
var missing []string
|
||||
for _, lot := range lotNames {
|
||||
if categories[lot] == "" {
|
||||
missing = append(missing, lot)
|
||||
}
|
||||
}
|
||||
|
||||
// Total row
|
||||
if err := w.Write([]string{"", "", "", "", "ИТОГО:", fmt.Sprintf("%.2f", data.Total)}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
w.Flush()
|
||||
return buf.Bytes(), w.Error()
|
||||
}
|
||||
|
||||
func (s *ExportService) ConfigToExportData(config *models.Configuration, componentService *ComponentService) *ExportData {
|
||||
items := make([]ExportItem, len(config.Items))
|
||||
var total float64
|
||||
|
||||
for i, item := range config.Items {
|
||||
itemTotal := item.UnitPrice * float64(item.Quantity)
|
||||
|
||||
// Получаем информацию о компоненте для заполнения категории
|
||||
componentView, err := componentService.GetByLotName(item.LotName)
|
||||
if err != nil {
|
||||
// Если не удалось получить информацию о компоненте, используем только основные данные
|
||||
items[i] = ExportItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: item.UnitPrice,
|
||||
TotalPrice: itemTotal,
|
||||
}
|
||||
} else {
|
||||
items[i] = ExportItem{
|
||||
LotName: item.LotName,
|
||||
Description: componentView.Description,
|
||||
Category: componentView.Category,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: item.UnitPrice,
|
||||
TotalPrice: itemTotal,
|
||||
if len(missing) > 0 {
|
||||
if fallback, err := s.localDB.GetLocalComponentCategoriesByLotNames(missing); err == nil {
|
||||
for lot, cat := range fallback {
|
||||
if strings.TrimSpace(cat) != "" {
|
||||
categories[lot] = cat
|
||||
}
|
||||
}
|
||||
}
|
||||
total += itemTotal
|
||||
}
|
||||
|
||||
return &ExportData{
|
||||
Name: config.Name,
|
||||
Items: items,
|
||||
Total: total,
|
||||
Notes: config.Notes,
|
||||
CreatedAt: config.CreatedAt,
|
||||
return categories
|
||||
}
|
||||
|
||||
// sortItemsByCategory sorts items by category display order (items without category go to the end).
|
||||
func sortItemsByCategory(items []ExportItem, categoryOrder map[string]int) {
|
||||
for i := 0; i < len(items)-1; i++ {
|
||||
for j := i + 1; j < len(items); j++ {
|
||||
orderI, hasI := categoryOrder[items[i].Category]
|
||||
orderJ, hasJ := categoryOrder[items[j].Category]
|
||||
|
||||
if !hasI && hasJ {
|
||||
items[i], items[j] = items[j], items[i]
|
||||
} else if hasI && hasJ && orderI > orderJ {
|
||||
items[i], items[j] = items[j], items[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// formatPriceComma formats a price with comma as decimal separator (e.g., "2074,5").
|
||||
// Trailing zeros after the comma are trimmed, and if the value is an integer, no comma is shown.
|
||||
func formatPriceComma(value float64) string {
|
||||
if value == math.Trunc(value) {
|
||||
return fmt.Sprintf("%.0f", value)
|
||||
}
|
||||
s := fmt.Sprintf("%.2f", value)
|
||||
s = strings.ReplaceAll(s, ".", ",")
|
||||
// Trim trailing zero: "2074,50" -> "2074,5"
|
||||
s = strings.TrimRight(s, "0")
|
||||
s = strings.TrimRight(s, ",")
|
||||
return s
|
||||
}
|
||||
|
||||
// formatPriceInt formats price as integer (rounded), no decimal.
|
||||
func formatPriceInt(value float64) string {
|
||||
return fmt.Sprintf("%.0f", math.Round(value))
|
||||
}
|
||||
|
||||
// formatPriceWithSpace formats a price as an integer with space as thousands separator (e.g., "104 700").
|
||||
func formatPriceWithSpace(value float64) string {
|
||||
intVal := int64(math.Round(value))
|
||||
if intVal < 0 {
|
||||
return "-" + formatIntWithSpace(-intVal)
|
||||
}
|
||||
return formatIntWithSpace(intVal)
|
||||
}
|
||||
|
||||
func formatIntWithSpace(n int64) string {
|
||||
s := fmt.Sprintf("%d", n)
|
||||
if len(s) <= 3 {
|
||||
return s
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
remainder := len(s) % 3
|
||||
if remainder > 0 {
|
||||
result.WriteString(s[:remainder])
|
||||
}
|
||||
for i := remainder; i < len(s); i += 3 {
|
||||
if result.Len() > 0 {
|
||||
result.WriteByte(' ')
|
||||
}
|
||||
result.WriteString(s[i : i+3])
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
452
internal/services/export_test.go
Normal file
452
internal/services/export_test.go
Normal file
@@ -0,0 +1,452 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
)
|
||||
|
||||
func newTestProjectData(items []ExportItem, article string, serverCount int) *ProjectExportData {
|
||||
var unitTotal float64
|
||||
for _, item := range items {
|
||||
unitTotal += item.UnitPrice * float64(item.Quantity)
|
||||
}
|
||||
if serverCount < 1 {
|
||||
serverCount = 1
|
||||
}
|
||||
return &ProjectExportData{
|
||||
Configs: []ConfigExportBlock{
|
||||
{
|
||||
Article: article,
|
||||
ServerCount: serverCount,
|
||||
UnitPrice: unitTotal,
|
||||
Items: items,
|
||||
},
|
||||
},
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCSV_UTF8BOM(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
|
||||
data := newTestProjectData([]ExportItem{
|
||||
{
|
||||
LotName: "LOT-001",
|
||||
Category: "CAT",
|
||||
Quantity: 1,
|
||||
UnitPrice: 100.0,
|
||||
TotalPrice: 100.0,
|
||||
},
|
||||
}, "TEST-ARTICLE", 1)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := svc.ToCSV(&buf, data); err != nil {
|
||||
t.Fatalf("ToCSV failed: %v", err)
|
||||
}
|
||||
|
||||
csvBytes := buf.Bytes()
|
||||
if len(csvBytes) < 3 {
|
||||
t.Fatalf("CSV too short to contain BOM")
|
||||
}
|
||||
|
||||
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
||||
actualBOM := csvBytes[:3]
|
||||
if !bytes.Equal(actualBOM, expectedBOM) {
|
||||
t.Errorf("UTF-8 BOM mismatch. Expected %v, got %v", expectedBOM, actualBOM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCSV_SemicolonDelimiter(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
|
||||
data := newTestProjectData([]ExportItem{
|
||||
{
|
||||
LotName: "LOT-001",
|
||||
Category: "CAT",
|
||||
Quantity: 2,
|
||||
UnitPrice: 100.50,
|
||||
TotalPrice: 201.00,
|
||||
},
|
||||
}, "TEST-ARTICLE", 1)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := svc.ToCSV(&buf, data); err != nil {
|
||||
t.Fatalf("ToCSV failed: %v", err)
|
||||
}
|
||||
|
||||
csvBytes := buf.Bytes()
|
||||
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
||||
reader.Comma = ';'
|
||||
|
||||
// Read header
|
||||
header, err := reader.Read()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read header: %v", err)
|
||||
}
|
||||
|
||||
if len(header) != 8 {
|
||||
t.Errorf("Expected 8 columns, got %d", len(header))
|
||||
}
|
||||
|
||||
expectedHeader := []string{"Line", "Type", "p/n", "Description", "Qty (1 pcs.)", "Qty (total)", "Price (1 pcs.)", "Price (total)"}
|
||||
for i, col := range expectedHeader {
|
||||
if i < len(header) && header[i] != col {
|
||||
t.Errorf("Column %d: expected %q, got %q", i, col, header[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Read server row
|
||||
serverRow, err := reader.Read()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read server row: %v", err)
|
||||
}
|
||||
if serverRow[0] != "10" {
|
||||
t.Errorf("Expected line number 10, got %s", serverRow[0])
|
||||
}
|
||||
if serverRow[2] != "TEST-ARTICLE" {
|
||||
t.Errorf("Expected article TEST-ARTICLE, got %s", serverRow[2])
|
||||
}
|
||||
|
||||
// Read component row
|
||||
itemRow, err := reader.Read()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read item row: %v", err)
|
||||
}
|
||||
if itemRow[2] != "LOT-001" {
|
||||
t.Errorf("Lot name mismatch: expected LOT-001, got %s", itemRow[2])
|
||||
}
|
||||
if itemRow[4] != "2" {
|
||||
t.Errorf("Quantity mismatch: expected 2, got %s", itemRow[4])
|
||||
}
|
||||
if itemRow[6] != "100,5" {
|
||||
t.Errorf("Unit price mismatch: expected 100,5, got %s", itemRow[6])
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCSV_ServerRow(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
|
||||
data := newTestProjectData([]ExportItem{
|
||||
{LotName: "LOT-001", Category: "CAT", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
||||
{LotName: "LOT-002", Category: "CAT", Quantity: 2, UnitPrice: 50.0, TotalPrice: 100.0},
|
||||
}, "DL380-ART", 10)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := svc.ToCSV(&buf, data); err != nil {
|
||||
t.Fatalf("ToCSV failed: %v", err)
|
||||
}
|
||||
|
||||
csvBytes := buf.Bytes()
|
||||
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
||||
reader.Comma = ';'
|
||||
|
||||
// Skip header
|
||||
reader.Read()
|
||||
|
||||
// Read server row
|
||||
serverRow, err := reader.Read()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read server row: %v", err)
|
||||
}
|
||||
|
||||
if serverRow[0] != "10" {
|
||||
t.Errorf("Expected line 10, got %s", serverRow[0])
|
||||
}
|
||||
if serverRow[2] != "DL380-ART" {
|
||||
t.Errorf("Expected article DL380-ART, got %s", serverRow[2])
|
||||
}
|
||||
if serverRow[5] != "10" {
|
||||
t.Errorf("Expected server count 10, got %s", serverRow[5])
|
||||
}
|
||||
// UnitPrice = 100 + 100 = 200
|
||||
if serverRow[6] != "200" {
|
||||
t.Errorf("Expected unit price 200, got %s", serverRow[6])
|
||||
}
|
||||
// TotalPrice = 200 * 10 = 2000
|
||||
if serverRow[7] != "2 000" {
|
||||
t.Errorf("Expected total price '2 000', got %q", serverRow[7])
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCSV_CategorySorting(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
|
||||
data := newTestProjectData([]ExportItem{
|
||||
{LotName: "LOT-001", Category: "CAT-A", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
||||
{LotName: "LOT-002", Category: "CAT-C", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
||||
{LotName: "LOT-003", Category: "CAT-B", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
||||
}, "ART", 1)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := svc.ToCSV(&buf, data); err != nil {
|
||||
t.Fatalf("ToCSV failed: %v", err)
|
||||
}
|
||||
|
||||
csvBytes := buf.Bytes()
|
||||
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
||||
reader.Comma = ';'
|
||||
|
||||
// Skip header and server row
|
||||
reader.Read()
|
||||
reader.Read()
|
||||
|
||||
// Without category repo, items maintain original order
|
||||
row1, _ := reader.Read()
|
||||
if row1[2] != "LOT-001" {
|
||||
t.Errorf("Expected LOT-001 first, got %s", row1[2])
|
||||
}
|
||||
|
||||
row2, _ := reader.Read()
|
||||
if row2[2] != "LOT-002" {
|
||||
t.Errorf("Expected LOT-002 second, got %s", row2[2])
|
||||
}
|
||||
|
||||
row3, _ := reader.Read()
|
||||
if row3[2] != "LOT-003" {
|
||||
t.Errorf("Expected LOT-003 third, got %s", row3[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCSV_EmptyData(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
|
||||
data := &ProjectExportData{
|
||||
Configs: []ConfigExportBlock{},
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := svc.ToCSV(&buf, data); err != nil {
|
||||
t.Fatalf("ToCSV failed: %v", err)
|
||||
}
|
||||
|
||||
csvBytes := buf.Bytes()
|
||||
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
||||
reader.Comma = ';'
|
||||
|
||||
header, err := reader.Read()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read header: %v", err)
|
||||
}
|
||||
|
||||
if len(header) != 8 {
|
||||
t.Errorf("Expected 8 columns, got %d", len(header))
|
||||
}
|
||||
|
||||
// No more rows expected
|
||||
_, err = reader.Read()
|
||||
if err != io.EOF {
|
||||
t.Errorf("Expected EOF after header, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCSVBytes_BackwardCompat(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
|
||||
data := newTestProjectData([]ExportItem{
|
||||
{LotName: "LOT-001", Category: "CAT", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
||||
}, "ART", 1)
|
||||
|
||||
csvBytes, err := svc.ToCSVBytes(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ToCSVBytes failed: %v", err)
|
||||
}
|
||||
|
||||
if len(csvBytes) < 3 {
|
||||
t.Fatalf("CSV bytes too short")
|
||||
}
|
||||
|
||||
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
||||
actualBOM := csvBytes[:3]
|
||||
if !bytes.Equal(actualBOM, expectedBOM) {
|
||||
t.Errorf("UTF-8 BOM mismatch in ToCSVBytes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCSV_WriterError(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
|
||||
data := newTestProjectData([]ExportItem{
|
||||
{LotName: "LOT-001", Category: "CAT", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
||||
}, "ART", 1)
|
||||
|
||||
failingWriter := &failingWriter{}
|
||||
|
||||
if err := svc.ToCSV(failingWriter, data); err == nil {
|
||||
t.Errorf("Expected error from failing writer, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCSV_MultipleBlocks(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
|
||||
data := &ProjectExportData{
|
||||
Configs: []ConfigExportBlock{
|
||||
{
|
||||
Article: "ART-1",
|
||||
ServerCount: 2,
|
||||
UnitPrice: 500.0,
|
||||
Items: []ExportItem{
|
||||
{LotName: "LOT-A", Category: "CPU", Quantity: 1, UnitPrice: 500.0, TotalPrice: 500.0},
|
||||
},
|
||||
},
|
||||
{
|
||||
Article: "ART-2",
|
||||
ServerCount: 3,
|
||||
UnitPrice: 1000.0,
|
||||
Items: []ExportItem{
|
||||
{LotName: "LOT-B", Category: "MEM", Quantity: 2, UnitPrice: 500.0, TotalPrice: 1000.0},
|
||||
},
|
||||
},
|
||||
},
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := svc.ToCSV(&buf, data); err != nil {
|
||||
t.Fatalf("ToCSV failed: %v", err)
|
||||
}
|
||||
|
||||
csvBytes := buf.Bytes()
|
||||
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
||||
reader.Comma = ';'
|
||||
reader.FieldsPerRecord = -1 // allow variable fields
|
||||
|
||||
// Header
|
||||
reader.Read()
|
||||
|
||||
// Block 1: server row
|
||||
srv1, _ := reader.Read()
|
||||
if srv1[0] != "10" {
|
||||
t.Errorf("Block 1 line: expected 10, got %s", srv1[0])
|
||||
}
|
||||
if srv1[7] != "1 000" {
|
||||
t.Errorf("Block 1 total: expected '1 000', got %q", srv1[7])
|
||||
}
|
||||
|
||||
// Block 1: component row
|
||||
comp1, _ := reader.Read()
|
||||
if comp1[2] != "LOT-A" {
|
||||
t.Errorf("Block 1 component: expected LOT-A, got %s", comp1[2])
|
||||
}
|
||||
|
||||
// Separator row
|
||||
sep, _ := reader.Read()
|
||||
allEmpty := true
|
||||
for _, v := range sep {
|
||||
if v != "" {
|
||||
allEmpty = false
|
||||
}
|
||||
}
|
||||
if !allEmpty {
|
||||
t.Errorf("Expected empty separator row, got %v", sep)
|
||||
}
|
||||
|
||||
// Block 2: server row
|
||||
srv2, _ := reader.Read()
|
||||
if srv2[0] != "20" {
|
||||
t.Errorf("Block 2 line: expected 20, got %s", srv2[0])
|
||||
}
|
||||
if srv2[7] != "3 000" {
|
||||
t.Errorf("Block 2 total: expected '3 000', got %q", srv2[7])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectToExportData_SortsByLine(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
|
||||
configs := []models.Configuration{
|
||||
{
|
||||
UUID: "cfg-1",
|
||||
Line: 30,
|
||||
Article: "ART-30",
|
||||
ServerCount: 1,
|
||||
Items: models.ConfigItems{{LotName: "LOT-30", Quantity: 1, UnitPrice: 300}},
|
||||
CreatedAt: time.Now().Add(-1 * time.Hour),
|
||||
},
|
||||
{
|
||||
UUID: "cfg-2",
|
||||
Line: 10,
|
||||
Article: "ART-10",
|
||||
ServerCount: 1,
|
||||
Items: models.ConfigItems{{LotName: "LOT-10", Quantity: 1, UnitPrice: 100}},
|
||||
CreatedAt: time.Now().Add(-2 * time.Hour),
|
||||
},
|
||||
{
|
||||
UUID: "cfg-3",
|
||||
Line: 20,
|
||||
Article: "ART-20",
|
||||
ServerCount: 1,
|
||||
Items: models.ConfigItems{{LotName: "LOT-20", Quantity: 1, UnitPrice: 200}},
|
||||
CreatedAt: time.Now().Add(-3 * time.Hour),
|
||||
},
|
||||
}
|
||||
|
||||
data := svc.ProjectToExportData(configs)
|
||||
if len(data.Configs) != 3 {
|
||||
t.Fatalf("expected 3 blocks, got %d", len(data.Configs))
|
||||
}
|
||||
if data.Configs[0].Article != "ART-10" || data.Configs[0].Line != 10 {
|
||||
t.Fatalf("first block must be line 10, got article=%s line=%d", data.Configs[0].Article, data.Configs[0].Line)
|
||||
}
|
||||
if data.Configs[1].Article != "ART-20" || data.Configs[1].Line != 20 {
|
||||
t.Fatalf("second block must be line 20, got article=%s line=%d", data.Configs[1].Article, data.Configs[1].Line)
|
||||
}
|
||||
if data.Configs[2].Article != "ART-30" || data.Configs[2].Line != 30 {
|
||||
t.Fatalf("third block must be line 30, got article=%s line=%d", data.Configs[2].Article, data.Configs[2].Line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPriceWithSpace(t *testing.T) {
|
||||
tests := []struct {
|
||||
input float64
|
||||
expected string
|
||||
}{
|
||||
{0, "0"},
|
||||
{100, "100"},
|
||||
{1000, "1 000"},
|
||||
{10470, "10 470"},
|
||||
{104700, "104 700"},
|
||||
{1000000, "1 000 000"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := formatPriceWithSpace(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("formatPriceWithSpace(%v): expected %q, got %q", tt.input, tt.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPriceComma(t *testing.T) {
|
||||
tests := []struct {
|
||||
input float64
|
||||
expected string
|
||||
}{
|
||||
{100.0, "100"},
|
||||
{2074.5, "2074,5"},
|
||||
{100.50, "100,5"},
|
||||
{99.99, "99,99"},
|
||||
{0, "0"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := formatPriceComma(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("formatPriceComma(%v): expected %q, got %q", tt.input, tt.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// failingWriter always returns an error
|
||||
type failingWriter struct{}
|
||||
|
||||
func (fw *failingWriter) Write(p []byte) (int, error) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
@@ -8,6 +8,7 @@ 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"
|
||||
@@ -64,6 +65,18 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
|
||||
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)
|
||||
@@ -80,6 +93,9 @@ 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(),
|
||||
@@ -91,6 +107,7 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
|
||||
if err := s.createWithVersion(localCfg, ownerUsername); err != nil {
|
||||
return nil, fmt.Errorf("create configuration with version: %w", err)
|
||||
}
|
||||
cfg.Line = localCfg.Line
|
||||
|
||||
// Record usage stats
|
||||
_ = s.quoteService.RecordUsage(req.Items)
|
||||
@@ -142,6 +159,18 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
|
||||
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)
|
||||
@@ -163,6 +192,9 @@ 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()
|
||||
@@ -176,6 +208,19 @@ 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)
|
||||
@@ -269,6 +314,9 @@ 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(),
|
||||
@@ -278,6 +326,7 @@ func (s *LocalConfigurationService) CloneToProject(configUUID string, ownerUsern
|
||||
if err := s.createWithVersion(localCfg, ownerUsername); err != nil {
|
||||
return nil, fmt.Errorf("clone configuration with version: %w", err)
|
||||
}
|
||||
clone.Line = localCfg.Line
|
||||
|
||||
return clone, nil
|
||||
}
|
||||
@@ -347,7 +396,7 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
||||
}
|
||||
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
|
||||
|
||||
// Update prices for all items
|
||||
// Update prices for all items from pricelist
|
||||
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
||||
for i, item := range localCfg.Items {
|
||||
if latestErr == nil && latestPricelist != nil {
|
||||
@@ -362,20 +411,8 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to 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
|
||||
}
|
||||
|
||||
// Update item with current price from local cache
|
||||
updatedItems[i] = localdb.LocalConfigItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: *component.CurrentPrice,
|
||||
}
|
||||
// Keep original item if price not found in pricelist
|
||||
updatedItems[i] = item
|
||||
}
|
||||
|
||||
// Update configuration
|
||||
@@ -407,14 +444,14 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
||||
|
||||
// GetByUUIDNoAuth returns configuration without ownership check
|
||||
func (s *LocalConfigurationService) GetByUUIDNoAuth(uuid string) (*models.Configuration, error) {
|
||||
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||
if err != nil {
|
||||
var localCfg localdb.LocalConfiguration
|
||||
if err := s.localDB.DB().Preload("CurrentVersion").Where("uuid = ?", uuid).First(&localCfg).Error; err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
if !localCfg.IsActive {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
return localdb.LocalToConfiguration(localCfg), nil
|
||||
return localdb.LocalToConfiguration(&localCfg), nil
|
||||
}
|
||||
|
||||
// UpdateNoAuth updates configuration without ownership check
|
||||
@@ -426,9 +463,21 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
|
||||
|
||||
projectUUID := localCfg.ProjectUUID
|
||||
if req.ProjectUUID != nil {
|
||||
requestedProjectUUID := strings.TrimSpace(*req.ProjectUUID)
|
||||
currentProjectUUID := ""
|
||||
if localCfg.ProjectUUID != nil {
|
||||
currentProjectUUID = strings.TrimSpace(*localCfg.ProjectUUID)
|
||||
}
|
||||
|
||||
projectUUID, err = s.resolveProjectUUID(localCfg.OriginalUsername, req.ProjectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// Allow save for legacy/orphaned configs when request keeps the same project UUID.
|
||||
// This can happen for imported configs whose project is not present in local cache.
|
||||
if errors.Is(err, ErrProjectNotFound) && requestedProjectUUID != "" && requestedProjectUUID == currentProjectUUID {
|
||||
projectUUID = localCfg.ProjectUUID
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
||||
@@ -436,6 +485,18 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
|
||||
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)
|
||||
@@ -456,6 +517,9 @@ 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()
|
||||
@@ -521,10 +585,30 @@ func (s *LocalConfigurationService) CloneNoAuth(configUUID string, newName strin
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) CloneNoAuthToProject(configUUID string, newName string, ownerUsername string, projectUUID *string) (*models.Configuration, error) {
|
||||
return s.CloneNoAuthToProjectFromVersion(configUUID, newName, ownerUsername, projectUUID, 0)
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) CloneNoAuthToProjectFromVersion(configUUID string, newName string, ownerUsername string, projectUUID *string, fromVersion int) (*models.Configuration, error) {
|
||||
original, err := s.GetByUUIDNoAuth(configUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If fromVersion specified, use snapshot from that version
|
||||
if fromVersion > 0 {
|
||||
version, vErr := s.GetVersion(configUUID, fromVersion)
|
||||
if vErr != nil {
|
||||
return nil, vErr
|
||||
}
|
||||
snapshot, decErr := s.decodeConfigurationSnapshot(version.Data)
|
||||
if decErr != nil {
|
||||
return nil, fmt.Errorf("decode version snapshot for clone: %w", decErr)
|
||||
}
|
||||
snapshotCfg := localdb.LocalToConfiguration(snapshot)
|
||||
original = snapshotCfg
|
||||
original.UUID = configUUID // preserve original UUID for project resolution
|
||||
}
|
||||
|
||||
resolvedProjectUUID := original.ProjectUUID
|
||||
if projectUUID != nil {
|
||||
resolvedProjectUUID, err = s.resolveProjectUUID(ownerUsername, projectUUID)
|
||||
@@ -558,6 +642,7 @@ func (s *LocalConfigurationService) CloneNoAuthToProject(configUUID string, newN
|
||||
if err := s.createWithVersion(localCfg, ownerUsername); err != nil {
|
||||
return nil, fmt.Errorf("clone configuration without auth with version: %w", err)
|
||||
}
|
||||
clone.Line = localCfg.Line
|
||||
|
||||
return clone, nil
|
||||
}
|
||||
@@ -672,7 +757,7 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
|
||||
}
|
||||
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
|
||||
|
||||
// Update prices for all items
|
||||
// Update prices for all items from pricelist
|
||||
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
||||
for i, item := range localCfg.Items {
|
||||
if latestErr == nil && latestPricelist != nil {
|
||||
@@ -687,20 +772,8 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to 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
|
||||
}
|
||||
|
||||
// Update item with current price from local cache
|
||||
updatedItems[i] = localdb.LocalConfigItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: *component.CurrentPrice,
|
||||
}
|
||||
// Keep original item if price not found in pricelist
|
||||
updatedItems[i] = item
|
||||
}
|
||||
|
||||
// Update configuration
|
||||
@@ -730,6 +803,143 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// UpdateServerCount updates server count and recalculates total price without creating a new version.
|
||||
func (s *LocalConfigurationService) UpdateServerCount(configUUID string, serverCount int) (*models.Configuration, error) {
|
||||
if serverCount < 1 {
|
||||
return nil, fmt.Errorf("server count must be at least 1")
|
||||
}
|
||||
|
||||
localCfg, err := s.localDB.GetConfigurationByUUID(configUUID)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
localCfg.ServerCount = serverCount
|
||||
total := localCfg.Items.Total()
|
||||
if serverCount > 1 {
|
||||
total *= float64(serverCount)
|
||||
}
|
||||
localCfg.TotalPrice = &total
|
||||
localCfg.UpdatedAt = time.Now()
|
||||
localCfg.SyncStatus = "pending"
|
||||
|
||||
var cfg *models.Configuration
|
||||
err = s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Save(localCfg).Error; err != nil {
|
||||
return fmt.Errorf("save local configuration: %w", err)
|
||||
}
|
||||
|
||||
version, err := s.loadVersionForPendingTx(tx, localCfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg = localdb.LocalToConfiguration(localCfg)
|
||||
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "update", version, ""); err != nil {
|
||||
return fmt.Errorf("enqueue server-count pending change: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) ReorderProjectConfigurationsNoAuth(projectUUID string, orderedUUIDs []string) ([]models.Configuration, error) {
|
||||
projectUUID = strings.TrimSpace(projectUUID)
|
||||
if projectUUID == "" {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
if _, err := s.localDB.GetProjectByUUID(projectUUID); err != nil {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
if len(orderedUUIDs) == 0 {
|
||||
return []models.Configuration{}, nil
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(orderedUUIDs))
|
||||
normalized := make([]string, 0, len(orderedUUIDs))
|
||||
for _, raw := range orderedUUIDs {
|
||||
u := strings.TrimSpace(raw)
|
||||
if u == "" {
|
||||
return nil, fmt.Errorf("ordered_uuids contains empty uuid")
|
||||
}
|
||||
if _, exists := seen[u]; exists {
|
||||
return nil, fmt.Errorf("ordered_uuids contains duplicate uuid: %s", u)
|
||||
}
|
||||
seen[u] = struct{}{}
|
||||
normalized = append(normalized, u)
|
||||
}
|
||||
|
||||
err := s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
||||
var active []localdb.LocalConfiguration
|
||||
if err := tx.Where("project_uuid = ? AND is_active = ?", projectUUID, true).
|
||||
Find(&active).Error; err != nil {
|
||||
return fmt.Errorf("load project active configurations: %w", err)
|
||||
}
|
||||
if len(active) != len(normalized) {
|
||||
return fmt.Errorf("ordered_uuids count mismatch: expected %d got %d", len(active), len(normalized))
|
||||
}
|
||||
|
||||
byUUID := make(map[string]*localdb.LocalConfiguration, len(active))
|
||||
for i := range active {
|
||||
cfg := active[i]
|
||||
byUUID[cfg.UUID] = &cfg
|
||||
}
|
||||
for _, id := range normalized {
|
||||
if _, ok := byUUID[id]; !ok {
|
||||
return fmt.Errorf("configuration %s not found in project %s", id, projectUUID)
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for idx, id := range normalized {
|
||||
cfg := byUUID[id]
|
||||
newLine := (idx + 1) * 10
|
||||
if cfg.Line == newLine {
|
||||
continue
|
||||
}
|
||||
|
||||
cfg.Line = newLine
|
||||
cfg.UpdatedAt = now
|
||||
cfg.SyncStatus = "pending"
|
||||
|
||||
if err := tx.Save(cfg).Error; err != nil {
|
||||
return fmt.Errorf("save reordered configuration %s: %w", cfg.UUID, err)
|
||||
}
|
||||
|
||||
version, err := s.loadVersionForPendingTx(tx, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.enqueueConfigurationPendingChangeTx(tx, cfg, "update", version, ""); err != nil {
|
||||
return fmt.Errorf("enqueue reorder pending change for %s: %w", cfg.UUID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var localConfigs []localdb.LocalConfiguration
|
||||
if err := s.localDB.DB().
|
||||
Preload("CurrentVersion").
|
||||
Where("project_uuid = ? AND is_active = ?", projectUUID, true).
|
||||
Order("CASE WHEN COALESCE(line_no, 0) <= 0 THEN 2147483647 ELSE line_no END ASC, created_at DESC, id DESC").
|
||||
Find(&localConfigs).Error; err != nil {
|
||||
return nil, fmt.Errorf("load reordered configurations: %w", err)
|
||||
}
|
||||
|
||||
result := make([]models.Configuration, 0, len(localConfigs))
|
||||
for i := range localConfigs {
|
||||
result = append(result, *localdb.LocalToConfiguration(&localConfigs[i]))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ImportFromServer imports configurations from MariaDB to local SQLite cache.
|
||||
func (s *LocalConfigurationService) ImportFromServer() (*sync.ConfigImportResult, error) {
|
||||
return s.syncService.ImportConfigurationsToLocal()
|
||||
@@ -843,6 +1053,11 @@ func (s *LocalConfigurationService) isOwner(cfg *localdb.LocalConfiguration, own
|
||||
|
||||
func (s *LocalConfigurationService) createWithVersion(localCfg *localdb.LocalConfiguration, createdBy string) error {
|
||||
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
||||
if localCfg.IsActive && localCfg.Line <= 0 {
|
||||
if err := s.ensureConfigurationLineTx(tx, localCfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Create(localCfg).Error; err != nil {
|
||||
return fmt.Errorf("create local configuration: %w", err)
|
||||
}
|
||||
@@ -858,6 +1073,7 @@ func (s *LocalConfigurationService) createWithVersion(localCfg *localdb.LocalCon
|
||||
return fmt.Errorf("set current version id: %w", err)
|
||||
}
|
||||
localCfg.CurrentVersionID = &version.ID
|
||||
localCfg.CurrentVersion = version
|
||||
|
||||
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "create", version, createdBy); err != nil {
|
||||
return fmt.Errorf("enqueue create pending change: %w", err)
|
||||
@@ -884,6 +1100,45 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
|
||||
return fmt.Errorf("lock configuration row: %w", err)
|
||||
}
|
||||
|
||||
if operation == "update" {
|
||||
currentVersion, err := s.loadCurrentVersionTx(tx, &locked)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load current version before save: %w", err)
|
||||
}
|
||||
|
||||
// Legacy/orphaned rows may have empty or stale current_version_id.
|
||||
// In that case we treat update as content-changing and append a fresh version.
|
||||
if currentVersion != nil {
|
||||
sameRevisionContent, err := s.hasSameRevisionContent(localCfg, currentVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compare revision content: %w", err)
|
||||
}
|
||||
if sameRevisionContent {
|
||||
if !hasNonRevisionConfigurationChanges(&locked, localCfg) {
|
||||
cfg = localdb.LocalToConfiguration(&locked)
|
||||
return nil
|
||||
}
|
||||
if err := tx.Save(localCfg).Error; err != nil {
|
||||
return fmt.Errorf("save local configuration (no new revision): %w", err)
|
||||
}
|
||||
cfg = localdb.LocalToConfiguration(localCfg)
|
||||
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, operation, currentVersion, createdBy); err != nil {
|
||||
return fmt.Errorf("enqueue %s pending change without revision: %w", operation, err)
|
||||
}
|
||||
if err := s.recalculateLocalPricelistUsageTx(tx); err != nil {
|
||||
return fmt.Errorf("recalculate local pricelist usage: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if localCfg.IsActive && localCfg.Line <= 0 {
|
||||
if err := s.ensureConfigurationLineTx(tx, localCfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Save(localCfg).Error; err != nil {
|
||||
return fmt.Errorf("save local configuration: %w", err)
|
||||
}
|
||||
@@ -899,6 +1154,7 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
|
||||
return fmt.Errorf("update current version id: %w", err)
|
||||
}
|
||||
localCfg.CurrentVersionID = &version.ID
|
||||
localCfg.CurrentVersion = version
|
||||
|
||||
cfg = localdb.LocalToConfiguration(localCfg)
|
||||
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, operation, version, createdBy); err != nil {
|
||||
@@ -917,6 +1173,114 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func hasNonRevisionConfigurationChanges(current *localdb.LocalConfiguration, next *localdb.LocalConfiguration) bool {
|
||||
if current == nil || next == nil {
|
||||
return true
|
||||
}
|
||||
if current.Name != next.Name ||
|
||||
current.Notes != next.Notes ||
|
||||
current.IsTemplate != next.IsTemplate ||
|
||||
current.ServerModel != next.ServerModel ||
|
||||
current.SupportCode != next.SupportCode ||
|
||||
current.Article != next.Article ||
|
||||
current.OnlyInStock != next.OnlyInStock ||
|
||||
current.IsActive != next.IsActive ||
|
||||
current.Line != next.Line {
|
||||
return true
|
||||
}
|
||||
if !equalUintPtr(current.PricelistID, next.PricelistID) ||
|
||||
!equalUintPtr(current.WarehousePricelistID, next.WarehousePricelistID) ||
|
||||
!equalUintPtr(current.CompetitorPricelistID, next.CompetitorPricelistID) ||
|
||||
!equalStringPtr(current.ProjectUUID, next.ProjectUUID) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func equalStringPtr(a, b *string) bool {
|
||||
if a == nil && b == nil {
|
||||
return true
|
||||
}
|
||||
if a == nil || b == nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(*a) == strings.TrimSpace(*b)
|
||||
}
|
||||
|
||||
func equalUintPtr(a, b *uint) bool {
|
||||
if a == nil && b == nil {
|
||||
return true
|
||||
}
|
||||
if a == nil || b == nil {
|
||||
return false
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) loadCurrentVersionTx(tx *gorm.DB, localCfg *localdb.LocalConfiguration) (*localdb.LocalConfigurationVersion, error) {
|
||||
var version localdb.LocalConfigurationVersion
|
||||
if localCfg.CurrentVersionID != nil && *localCfg.CurrentVersionID != "" {
|
||||
if err := tx.Where("id = ?", *localCfg.CurrentVersionID).First(&version).Error; err == nil {
|
||||
return &version, nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Where("configuration_uuid = ?", localCfg.UUID).
|
||||
Order("version_no DESC").
|
||||
First(&version).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &version, nil
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) loadVersionForPendingTx(tx *gorm.DB, localCfg *localdb.LocalConfiguration) (*localdb.LocalConfigurationVersion, error) {
|
||||
if localCfg.CurrentVersionID != nil && *localCfg.CurrentVersionID != "" {
|
||||
var current localdb.LocalConfigurationVersion
|
||||
if err := tx.Where("id = ?", *localCfg.CurrentVersionID).First(¤t).Error; err == nil {
|
||||
return ¤t, nil
|
||||
}
|
||||
}
|
||||
|
||||
var latest localdb.LocalConfigurationVersion
|
||||
if err := tx.Where("configuration_uuid = ?", localCfg.UUID).
|
||||
Order("version_no DESC").
|
||||
First(&latest).Error; err != nil {
|
||||
return nil, fmt.Errorf("load version for pending change: %w", err)
|
||||
}
|
||||
return &latest, nil
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) ensureConfigurationLineTx(tx *gorm.DB, localCfg *localdb.LocalConfiguration) error {
|
||||
line, err := localdb.NextConfigurationLineTx(tx, localCfg.ProjectUUID, localCfg.UUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("assign line_no for configuration %s: %w", localCfg.UUID, err)
|
||||
}
|
||||
localCfg.Line = line
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) hasSameRevisionContent(localCfg *localdb.LocalConfiguration, currentVersion *localdb.LocalConfigurationVersion) (bool, error) {
|
||||
currentSnapshotCfg, err := s.decodeConfigurationSnapshot(currentVersion.Data)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("decode current version snapshot: %w", err)
|
||||
}
|
||||
|
||||
currentFingerprint, err := localdb.BuildConfigurationSpecPriceFingerprint(currentSnapshotCfg)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("build current snapshot fingerprint: %w", err)
|
||||
}
|
||||
nextFingerprint, err := localdb.BuildConfigurationSpecPriceFingerprint(localCfg)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("build next snapshot fingerprint: %w", err)
|
||||
}
|
||||
return currentFingerprint == nextFingerprint, nil
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) appendVersionTx(
|
||||
tx *gorm.DB,
|
||||
localCfg *localdb.LocalConfiguration,
|
||||
@@ -1013,6 +1377,9 @@ func (s *LocalConfigurationService) rollbackToVersion(configurationUUID string,
|
||||
current.ServerCount = rollbackData.ServerCount
|
||||
current.PricelistID = rollbackData.PricelistID
|
||||
current.OnlyInStock = rollbackData.OnlyInStock
|
||||
if rollbackData.Line > 0 {
|
||||
current.Line = rollbackData.Line
|
||||
}
|
||||
current.PriceUpdatedAt = rollbackData.PriceUpdatedAt
|
||||
current.UpdatedAt = time.Now()
|
||||
current.SyncStatus = "pending"
|
||||
|
||||
@@ -27,8 +27,12 @@ func TestSaveCreatesNewVersionAndUpdatesCurrentPointer(t *testing.T) {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
|
||||
if _, err := service.RenameNoAuth(created.UUID, "v2"); err != nil {
|
||||
t.Fatalf("rename config: %v", err)
|
||||
if _, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||
Name: "v1",
|
||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 1000}},
|
||||
ServerCount: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("update config: %v", err)
|
||||
}
|
||||
|
||||
versions := loadVersions(t, local, created.UUID)
|
||||
@@ -60,8 +64,12 @@ func TestRollbackCreatesNewVersionWithTargetData(t *testing.T) {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
|
||||
if _, err := service.RenameNoAuth(created.UUID, "changed"); err != nil {
|
||||
t.Fatalf("rename config: %v", err)
|
||||
if _, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||
Name: "base",
|
||||
Items: models.ConfigItems{{LotName: "RAM_A", Quantity: 3, UnitPrice: 100}},
|
||||
ServerCount: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("update config: %v", err)
|
||||
}
|
||||
if _, err := service.RollbackToVersionWithNote(created.UUID, 1, "tester", "test rollback"); err != nil {
|
||||
t.Fatalf("rollback to v1: %v", err)
|
||||
@@ -79,6 +87,128 @@ func TestRollbackCreatesNewVersionWithTargetData(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNoAuthSkipsRevisionWhenSpecAndPriceUnchanged(t *testing.T) {
|
||||
service, local := newLocalConfigServiceForTest(t)
|
||||
|
||||
created, err := service.Create("tester", &CreateConfigRequest{
|
||||
Name: "dedupe",
|
||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
|
||||
_, err = service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||
Name: "dedupe",
|
||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 1000}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("first update config: %v", err)
|
||||
}
|
||||
|
||||
_, err = service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||
Name: "dedupe",
|
||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 1000}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("second update config: %v", err)
|
||||
}
|
||||
|
||||
versions := loadVersions(t, local, created.UUID)
|
||||
if len(versions) != 2 {
|
||||
t.Fatalf("expected 2 versions (create + first update), got %d", len(versions))
|
||||
}
|
||||
if versions[1].VersionNo != 2 {
|
||||
t.Fatalf("expected latest version_no=2, got %d", versions[1].VersionNo)
|
||||
}
|
||||
|
||||
var pendingCount int64
|
||||
if err := local.DB().
|
||||
Table("pending_changes").
|
||||
Where("entity_type = ? AND entity_uuid = ?", "configuration", created.UUID).
|
||||
Count(&pendingCount).Error; err != nil {
|
||||
t.Fatalf("count pending changes: %v", err)
|
||||
}
|
||||
if pendingCount != 2 {
|
||||
t.Fatalf("expected 2 pending changes (create + first update), got %d", pendingCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderProjectConfigurationsDoesNotCreateNewVersions(t *testing.T) {
|
||||
service, local := newLocalConfigServiceForTest(t)
|
||||
|
||||
project := &localdb.LocalProject{
|
||||
UUID: "project-reorder",
|
||||
OwnerUsername: "tester",
|
||||
Code: "PRJ-ORDER",
|
||||
Variant: "",
|
||||
Name: ptrString("Project Reorder"),
|
||||
IsActive: true,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
SyncStatus: "pending",
|
||||
}
|
||||
if err := local.SaveProject(project); err != nil {
|
||||
t.Fatalf("save project: %v", err)
|
||||
}
|
||||
|
||||
first, err := service.Create("tester", &CreateConfigRequest{
|
||||
Name: "Cfg A",
|
||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 100}},
|
||||
ServerCount: 1,
|
||||
ProjectUUID: &project.UUID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create first config: %v", err)
|
||||
}
|
||||
second, err := service.Create("tester", &CreateConfigRequest{
|
||||
Name: "Cfg B",
|
||||
Items: models.ConfigItems{{LotName: "CPU_B", Quantity: 1, UnitPrice: 200}},
|
||||
ServerCount: 1,
|
||||
ProjectUUID: &project.UUID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create second config: %v", err)
|
||||
}
|
||||
|
||||
beforeFirst := loadVersions(t, local, first.UUID)
|
||||
beforeSecond := loadVersions(t, local, second.UUID)
|
||||
|
||||
reordered, err := service.ReorderProjectConfigurationsNoAuth(project.UUID, []string{second.UUID, first.UUID})
|
||||
if err != nil {
|
||||
t.Fatalf("reorder configurations: %v", err)
|
||||
}
|
||||
if len(reordered) != 2 {
|
||||
t.Fatalf("expected 2 reordered configs, got %d", len(reordered))
|
||||
}
|
||||
if reordered[0].UUID != second.UUID || reordered[0].Line != 10 {
|
||||
t.Fatalf("expected second config first with line 10, got uuid=%s line=%d", reordered[0].UUID, reordered[0].Line)
|
||||
}
|
||||
if reordered[1].UUID != first.UUID || reordered[1].Line != 20 {
|
||||
t.Fatalf("expected first config second with line 20, got uuid=%s line=%d", reordered[1].UUID, reordered[1].Line)
|
||||
}
|
||||
|
||||
afterFirst := loadVersions(t, local, first.UUID)
|
||||
afterSecond := loadVersions(t, local, second.UUID)
|
||||
if len(afterFirst) != len(beforeFirst) || len(afterSecond) != len(beforeSecond) {
|
||||
t.Fatalf("reorder must not create new versions")
|
||||
}
|
||||
|
||||
var pendingCount int64
|
||||
if err := local.DB().
|
||||
Table("pending_changes").
|
||||
Where("entity_type = ? AND operation = ? AND entity_uuid IN ?", "configuration", "update", []string{first.UUID, second.UUID}).
|
||||
Count(&pendingCount).Error; err != nil {
|
||||
t.Fatalf("count reorder pending changes: %v", err)
|
||||
}
|
||||
if pendingCount < 2 {
|
||||
t.Fatalf("expected at least 2 pending update changes for reorder, got %d", pendingCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendOnlyInvariantOldRowsUnchanged(t *testing.T) {
|
||||
service, local := newLocalConfigServiceForTest(t)
|
||||
|
||||
@@ -97,8 +227,12 @@ func TestAppendOnlyInvariantOldRowsUnchanged(t *testing.T) {
|
||||
}
|
||||
v1Before := versionsBefore[0]
|
||||
|
||||
if _, err := service.RenameNoAuth(created.UUID, "after-rename"); err != nil {
|
||||
t.Fatalf("rename config: %v", err)
|
||||
if _, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||
Name: "initial",
|
||||
Items: models.ConfigItems{{LotName: "SSD_A", Quantity: 2, UnitPrice: 300}},
|
||||
ServerCount: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("update config: %v", err)
|
||||
}
|
||||
if _, err := service.RollbackToVersion(created.UUID, 1, "tester"); err != nil {
|
||||
t.Fatalf("rollback: %v", err)
|
||||
@@ -144,7 +278,7 @@ func TestConcurrentSaveNoDuplicateVersionNumbers(t *testing.T) {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
if err := renameWithRetry(service, created.UUID, fmt.Sprintf("name-%d", i)); err != nil {
|
||||
if err := updateWithRetry(service, created.UUID, i+2); err != nil {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
@@ -191,7 +325,8 @@ func TestUpdateNoAuthKeepsProjectWhenProjectUUIDOmitted(t *testing.T) {
|
||||
project := &localdb.LocalProject{
|
||||
UUID: "project-keep",
|
||||
OwnerUsername: "tester",
|
||||
Name: "Keep Project",
|
||||
Code: "TEST-KEEP",
|
||||
Name: ptrString("Keep Project"),
|
||||
IsActive: true,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
@@ -227,6 +362,103 @@ func TestUpdateNoAuthKeepsProjectWhenProjectUUIDOmitted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNoAuthAllowsOrphanProjectWhenUUIDUnchanged(t *testing.T) {
|
||||
service, local := newLocalConfigServiceForTest(t)
|
||||
|
||||
project := &localdb.LocalProject{
|
||||
UUID: "project-orphan",
|
||||
OwnerUsername: "tester",
|
||||
Code: "TEST-ORPHAN",
|
||||
Name: ptrString("Orphan Project"),
|
||||
IsActive: true,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
SyncStatus: "synced",
|
||||
}
|
||||
if err := local.SaveProject(project); err != nil {
|
||||
t.Fatalf("save project: %v", err)
|
||||
}
|
||||
|
||||
created, err := service.Create("tester", &CreateConfigRequest{
|
||||
Name: "cfg",
|
||||
ProjectUUID: &project.UUID,
|
||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 100}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
|
||||
// Simulate missing project in local cache while config still references its UUID.
|
||||
if err := local.DB().Where("uuid = ?", project.UUID).Delete(&localdb.LocalProject{}).Error; err != nil {
|
||||
t.Fatalf("delete project: %v", err)
|
||||
}
|
||||
|
||||
updated, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||
Name: "cfg-updated",
|
||||
ProjectUUID: &project.UUID,
|
||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 100}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update config with orphan project_uuid: %v", err)
|
||||
}
|
||||
if updated.ProjectUUID == nil || *updated.ProjectUUID != project.UUID {
|
||||
t.Fatalf("expected project_uuid to stay %s after update, got %+v", project.UUID, updated.ProjectUUID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNoAuthRecoversWhenCurrentVersionMissing(t *testing.T) {
|
||||
service, local := newLocalConfigServiceForTest(t)
|
||||
|
||||
created, err := service.Create("tester", &CreateConfigRequest{
|
||||
Name: "cfg",
|
||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 100}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
|
||||
// Simulate corrupted/legacy versioning state:
|
||||
// local configuration exists, but all version rows are gone and pointer is stale.
|
||||
if err := local.DB().Where("configuration_uuid = ?", created.UUID).
|
||||
Delete(&localdb.LocalConfigurationVersion{}).Error; err != nil {
|
||||
t.Fatalf("delete versions: %v", err)
|
||||
}
|
||||
staleID := "missing-version-id"
|
||||
if err := local.DB().Model(&localdb.LocalConfiguration{}).
|
||||
Where("uuid = ?", created.UUID).
|
||||
Update("current_version_id", staleID).Error; err != nil {
|
||||
t.Fatalf("set stale current_version_id: %v", err)
|
||||
}
|
||||
|
||||
updated, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||
Name: "cfg-updated",
|
||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 100}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update config with missing current version: %v", err)
|
||||
}
|
||||
|
||||
if updated.Name != "cfg-updated" {
|
||||
t.Fatalf("expected updated name, got %q", updated.Name)
|
||||
}
|
||||
|
||||
versions := loadVersions(t, local, created.UUID)
|
||||
if len(versions) != 1 {
|
||||
t.Fatalf("expected 1 recreated version, got %d", len(versions))
|
||||
}
|
||||
if versions[0].VersionNo != 1 {
|
||||
t.Fatalf("expected recreated version_no=1, got %d", versions[0].VersionNo)
|
||||
}
|
||||
}
|
||||
|
||||
func ptrString(value string) *string {
|
||||
return &value
|
||||
}
|
||||
|
||||
func newLocalConfigServiceForTest(t *testing.T) (*LocalConfigurationService, *localdb.LocalDB) {
|
||||
t.Helper()
|
||||
|
||||
@@ -259,10 +491,14 @@ func loadVersions(t *testing.T, local *localdb.LocalDB, configurationUUID string
|
||||
return versions
|
||||
}
|
||||
|
||||
func renameWithRetry(service *LocalConfigurationService, uuid string, name string) error {
|
||||
func updateWithRetry(service *LocalConfigurationService, uuid string, quantity int) error {
|
||||
var lastErr error
|
||||
for i := 0; i < 6; i++ {
|
||||
_, err := service.RenameNoAuth(uuid, name)
|
||||
_, err := service.UpdateNoAuth(uuid, &CreateConfigRequest{
|
||||
Name: "base",
|
||||
Items: models.ConfigItems{{LotName: "NIC_A", Quantity: quantity, UnitPrice: 150}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -273,7 +509,7 @@ func renameWithRetry(service *LocalConfigurationService, uuid string, name strin
|
||||
}
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("rename retries exhausted: %w", lastErr)
|
||||
return fmt.Errorf("update retries exhausted: %w", lastErr)
|
||||
}
|
||||
|
||||
func TestRollbackVersionSnapshotJSONMatchesV1(t *testing.T) {
|
||||
@@ -287,8 +523,12 @@ func TestRollbackVersionSnapshotJSONMatchesV1(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
if _, err := service.RenameNoAuth(created.UUID, "second"); err != nil {
|
||||
t.Fatalf("rename: %v", err)
|
||||
if _, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||
Name: "initial",
|
||||
Items: models.ConfigItems{{LotName: "GPU_A", Quantity: 2, UnitPrice: 2000}},
|
||||
ServerCount: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
if _, err := service.RollbackToVersion(created.UUID, 1, "tester"); err != nil {
|
||||
t.Fatalf("rollback: %v", err)
|
||||
|
||||
@@ -16,8 +16,10 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrProjectNotFound = errors.New("project not found")
|
||||
ErrProjectForbidden = errors.New("access to project forbidden")
|
||||
ErrProjectNotFound = errors.New("project not found")
|
||||
ErrProjectForbidden = errors.New("access to project forbidden")
|
||||
ErrProjectCodeExists = errors.New("project code and variant already exist")
|
||||
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
|
||||
)
|
||||
|
||||
type ProjectService struct {
|
||||
@@ -29,12 +31,16 @@ func NewProjectService(localDB *localdb.LocalDB) *ProjectService {
|
||||
}
|
||||
|
||||
type CreateProjectRequest struct {
|
||||
Name string `json:"name"`
|
||||
TrackerURL string `json:"tracker_url"`
|
||||
Code string `json:"code"`
|
||||
Variant string `json:"variant,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
TrackerURL string `json:"tracker_url"`
|
||||
}
|
||||
|
||||
type UpdateProjectRequest struct {
|
||||
Name string `json:"name"`
|
||||
Code *string `json:"code,omitempty"`
|
||||
Variant *string `json:"variant,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
TrackerURL *string `json:"tracker_url,omitempty"`
|
||||
}
|
||||
|
||||
@@ -45,17 +51,30 @@ type ProjectConfigurationsResult struct {
|
||||
}
|
||||
|
||||
func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest) (*models.Project, error) {
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("project name is required")
|
||||
var namePtr *string
|
||||
if req.Name != nil {
|
||||
name := strings.TrimSpace(*req.Name)
|
||||
if name != "" {
|
||||
namePtr = &name
|
||||
}
|
||||
}
|
||||
code := strings.TrimSpace(req.Code)
|
||||
if code == "" {
|
||||
return nil, fmt.Errorf("project code is required")
|
||||
}
|
||||
variant := strings.TrimSpace(req.Variant)
|
||||
if err := s.ensureUniqueProjectCodeVariant("", code, variant); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
localProject := &localdb.LocalProject{
|
||||
UUID: uuid.NewString(),
|
||||
OwnerUsername: ownerUsername,
|
||||
Name: name,
|
||||
TrackerURL: normalizeProjectTrackerURL(name, req.TrackerURL),
|
||||
Code: code,
|
||||
Variant: variant,
|
||||
Name: namePtr,
|
||||
TrackerURL: normalizeProjectTrackerURL(code, req.TrackerURL),
|
||||
IsActive: true,
|
||||
IsSystem: false,
|
||||
CreatedAt: now,
|
||||
@@ -76,20 +95,33 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
|
||||
if err != nil {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
if localProject.OwnerUsername != ownerUsername {
|
||||
return nil, ErrProjectForbidden
|
||||
|
||||
if req.Code != nil {
|
||||
code := strings.TrimSpace(*req.Code)
|
||||
if code == "" {
|
||||
return nil, fmt.Errorf("project code is required")
|
||||
}
|
||||
localProject.Code = code
|
||||
}
|
||||
if req.Variant != nil {
|
||||
localProject.Variant = strings.TrimSpace(*req.Variant)
|
||||
}
|
||||
if err := s.ensureUniqueProjectCodeVariant(projectUUID, localProject.Code, localProject.Variant); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("project name is required")
|
||||
if req.Name != nil {
|
||||
name := strings.TrimSpace(*req.Name)
|
||||
if name == "" {
|
||||
localProject.Name = nil
|
||||
} else {
|
||||
localProject.Name = &name
|
||||
}
|
||||
}
|
||||
|
||||
localProject.Name = name
|
||||
if req.TrackerURL != nil {
|
||||
localProject.TrackerURL = normalizeProjectTrackerURL(name, *req.TrackerURL)
|
||||
localProject.TrackerURL = normalizeProjectTrackerURL(localProject.Code, *req.TrackerURL)
|
||||
} else if strings.TrimSpace(localProject.TrackerURL) == "" {
|
||||
localProject.TrackerURL = normalizeProjectTrackerURL(name, "")
|
||||
localProject.TrackerURL = normalizeProjectTrackerURL(localProject.Code, "")
|
||||
}
|
||||
localProject.UpdatedAt = time.Now()
|
||||
localProject.SyncStatus = "pending"
|
||||
@@ -102,6 +134,38 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
|
||||
return localdb.LocalToProject(localProject), nil
|
||||
}
|
||||
|
||||
func (s *ProjectService) ensureUniqueProjectCodeVariant(excludeUUID, code, variant string) error {
|
||||
normalizedCode := normalizeProjectCode(code)
|
||||
normalizedVariant := normalizeProjectVariant(variant)
|
||||
if normalizedCode == "" {
|
||||
return fmt.Errorf("project code is required")
|
||||
}
|
||||
|
||||
projects, err := s.localDB.GetAllProjects(true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range projects {
|
||||
project := projects[i]
|
||||
if excludeUUID != "" && project.UUID == excludeUUID {
|
||||
continue
|
||||
}
|
||||
if normalizeProjectCode(project.Code) == normalizedCode &&
|
||||
normalizeProjectVariant(project.Variant) == normalizedVariant {
|
||||
return ErrProjectCodeExists
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeProjectCode(code string) string {
|
||||
return strings.ToLower(strings.TrimSpace(code))
|
||||
}
|
||||
|
||||
func normalizeProjectVariant(variant string) string {
|
||||
return strings.ToLower(strings.TrimSpace(variant))
|
||||
}
|
||||
|
||||
func (s *ProjectService) Archive(projectUUID, ownerUsername string) error {
|
||||
return s.setProjectActive(projectUUID, ownerUsername, false)
|
||||
}
|
||||
@@ -110,15 +174,23 @@ func (s *ProjectService) Reactivate(projectUUID, ownerUsername string) error {
|
||||
return s.setProjectActive(projectUUID, ownerUsername, true)
|
||||
}
|
||||
|
||||
func (s *ProjectService) DeleteVariant(projectUUID, ownerUsername string) error {
|
||||
localProject, err := s.localDB.GetProjectByUUID(projectUUID)
|
||||
if err != nil {
|
||||
return ErrProjectNotFound
|
||||
}
|
||||
if strings.TrimSpace(localProject.Variant) == "" {
|
||||
return ErrCannotDeleteMainVariant
|
||||
}
|
||||
return s.setProjectActive(projectUUID, ownerUsername, false)
|
||||
}
|
||||
|
||||
func (s *ProjectService) setProjectActive(projectUUID, ownerUsername string, isActive bool) error {
|
||||
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
||||
var project localdb.LocalProject
|
||||
if err := tx.Where("uuid = ?", projectUUID).First(&project).Error; err != nil {
|
||||
return ErrProjectNotFound
|
||||
}
|
||||
if project.OwnerUsername != ownerUsername {
|
||||
return ErrProjectForbidden
|
||||
}
|
||||
if project.IsActive == isActive {
|
||||
return nil
|
||||
}
|
||||
@@ -203,8 +275,23 @@ func (s *ProjectService) ListConfigurations(projectUUID, ownerUsername, status s
|
||||
}, nil
|
||||
}
|
||||
|
||||
localConfigs, err := s.localDB.GetConfigurations()
|
||||
if err != nil {
|
||||
query := s.localDB.DB().
|
||||
Preload("CurrentVersion").
|
||||
Where("project_uuid = ?", projectUUID).
|
||||
Order("CASE WHEN COALESCE(line_no, 0) <= 0 THEN 2147483647 ELSE line_no END ASC, created_at DESC, id DESC")
|
||||
|
||||
switch status {
|
||||
case "active", "":
|
||||
query = query.Where("is_active = ?", true)
|
||||
case "archived":
|
||||
query = query.Where("is_active = ?", false)
|
||||
case "all":
|
||||
default:
|
||||
query = query.Where("is_active = ?", true)
|
||||
}
|
||||
|
||||
var localConfigs []localdb.LocalConfiguration
|
||||
if err := query.Find(&localConfigs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -212,25 +299,6 @@ func (s *ProjectService) ListConfigurations(projectUUID, ownerUsername, status s
|
||||
total := 0.0
|
||||
for i := range localConfigs {
|
||||
localCfg := localConfigs[i]
|
||||
if localCfg.ProjectUUID == nil || *localCfg.ProjectUUID != projectUUID {
|
||||
continue
|
||||
}
|
||||
switch status {
|
||||
case "active", "":
|
||||
if !localCfg.IsActive {
|
||||
continue
|
||||
}
|
||||
case "archived":
|
||||
if localCfg.IsActive {
|
||||
continue
|
||||
}
|
||||
case "all":
|
||||
default:
|
||||
if !localCfg.IsActive {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
cfg := localdb.LocalToConfiguration(&localCfg)
|
||||
if cfg.TotalPrice != nil {
|
||||
total += *cfg.TotalPrice
|
||||
|
||||
@@ -78,6 +78,7 @@ 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 {
|
||||
@@ -123,6 +124,16 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
|
||||
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)
|
||||
@@ -142,13 +153,19 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
|
||||
TotalPrice: 0,
|
||||
}
|
||||
|
||||
if localComp.CurrentPrice != nil && *localComp.CurrentPrice > 0 {
|
||||
item.UnitPrice = *localComp.CurrentPrice
|
||||
item.TotalPrice = *localComp.CurrentPrice * float64(reqItem.Quantity)
|
||||
item.HasPrice = true
|
||||
total += item.TotalPrice
|
||||
// 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 price available for: "+reqItem.LotName)
|
||||
result.Warnings = append(result.Warnings, "No pricelist available for: "+reqItem.LotName)
|
||||
}
|
||||
|
||||
result.Items = append(result.Items, item)
|
||||
|
||||
410
internal/services/sync/readiness.go
Normal file
410
internal/services/sync/readiness.go
Normal file
@@ -0,0 +1,410 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
ReadinessReady = "ready"
|
||||
ReadinessBlocked = "blocked"
|
||||
ReadinessUnknown = "unknown"
|
||||
)
|
||||
|
||||
var ErrSyncBlockedByReadiness = errors.New("sync blocked by readiness guard")
|
||||
|
||||
type SyncReadiness struct {
|
||||
Status string `json:"status"`
|
||||
Blocked bool `json:"blocked"`
|
||||
ReasonCode string `json:"reason_code,omitempty"`
|
||||
ReasonText string `json:"reason_text,omitempty"`
|
||||
RequiredMinAppVersion *string `json:"required_min_app_version,omitempty"`
|
||||
LastCheckedAt *time.Time `json:"last_checked_at,omitempty"`
|
||||
}
|
||||
|
||||
type SyncBlockedError struct {
|
||||
Readiness SyncReadiness
|
||||
}
|
||||
|
||||
func (e *SyncBlockedError) Error() string {
|
||||
if e == nil {
|
||||
return ErrSyncBlockedByReadiness.Error()
|
||||
}
|
||||
if strings.TrimSpace(e.Readiness.ReasonText) != "" {
|
||||
return e.Readiness.ReasonText
|
||||
}
|
||||
return ErrSyncBlockedByReadiness.Error()
|
||||
}
|
||||
|
||||
func (s *Service) EnsureReadinessForSync() (*SyncReadiness, error) {
|
||||
readiness, err := s.GetReadiness()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if readiness.Blocked {
|
||||
return readiness, &SyncBlockedError{Readiness: *readiness}
|
||||
}
|
||||
return readiness, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetReadiness() (*SyncReadiness, error) {
|
||||
now := time.Now().UTC()
|
||||
if !s.isOnline() {
|
||||
return s.blockedReadiness(
|
||||
now,
|
||||
"OFFLINE_UNVERIFIED_SCHEMA",
|
||||
"Синхронизация недоступна: нет соединения с сервером и нельзя проверить миграции локальной БД.",
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
mariaDB, err := s.getDB()
|
||||
if err != nil || mariaDB == nil {
|
||||
return s.blockedReadiness(
|
||||
now,
|
||||
"OFFLINE_UNVERIFIED_SCHEMA",
|
||||
"Синхронизация недоступна: нет соединения с сервером и нельзя проверить миграции локальной БД.",
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
migrations, err := listActiveClientMigrations(mariaDB)
|
||||
if err != nil {
|
||||
return s.blockedReadiness(
|
||||
now,
|
||||
"REMOTE_MIGRATION_REGISTRY_UNAVAILABLE",
|
||||
"Синхронизация заблокирована: не удалось проверить централизованные миграции локальной БД.",
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
for i := range migrations {
|
||||
m := migrations[i]
|
||||
if strings.TrimSpace(m.MinAppVersion) != "" {
|
||||
if compareVersions(appmeta.Version(), m.MinAppVersion) < 0 {
|
||||
min := m.MinAppVersion
|
||||
return s.blockedReadiness(
|
||||
now,
|
||||
"MIN_APP_VERSION_REQUIRED",
|
||||
fmt.Sprintf("Требуется обновление приложения до версии %s для безопасной синхронизации.", m.MinAppVersion),
|
||||
&min,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.applyMissingRemoteMigrations(migrations); err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "checksum") {
|
||||
return s.blockedReadiness(
|
||||
now,
|
||||
"REMOTE_MIGRATION_CHECKSUM_MISMATCH",
|
||||
"Синхронизация заблокирована: контрольная сумма миграции не совпадает.",
|
||||
nil,
|
||||
)
|
||||
}
|
||||
return s.blockedReadiness(
|
||||
now,
|
||||
"LOCAL_MIGRATION_APPLY_FAILED",
|
||||
"Синхронизация заблокирована: не удалось применить миграции локальной БД.",
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
if err := s.reportClientSchemaState(mariaDB, now); err != nil {
|
||||
slog.Warn("failed to report client schema state", "error", err)
|
||||
}
|
||||
|
||||
ready := &SyncReadiness{Status: ReadinessReady, Blocked: false, LastCheckedAt: &now}
|
||||
if setErr := s.localDB.SetSyncGuardState(ReadinessReady, "", "", nil, &now); setErr != nil {
|
||||
slog.Warn("failed to persist sync guard state", "error", setErr)
|
||||
}
|
||||
return ready, nil
|
||||
}
|
||||
|
||||
func (s *Service) blockedReadiness(now time.Time, code, text string, minVersion *string) (*SyncReadiness, error) {
|
||||
readiness := &SyncReadiness{
|
||||
Status: ReadinessBlocked,
|
||||
Blocked: true,
|
||||
ReasonCode: code,
|
||||
ReasonText: text,
|
||||
RequiredMinAppVersion: minVersion,
|
||||
LastCheckedAt: &now,
|
||||
}
|
||||
if err := s.localDB.SetSyncGuardState(ReadinessBlocked, code, text, minVersion, &now); err != nil {
|
||||
slog.Warn("failed to persist blocked sync guard state", "error", err)
|
||||
}
|
||||
return readiness, nil
|
||||
}
|
||||
|
||||
func (s *Service) isOnline() bool {
|
||||
if s.directDB != nil {
|
||||
return true
|
||||
}
|
||||
if s.connMgr == nil {
|
||||
return false
|
||||
}
|
||||
return s.connMgr.IsOnline()
|
||||
}
|
||||
|
||||
type clientLocalMigration struct {
|
||||
ID string `gorm:"column:id"`
|
||||
Name string `gorm:"column:name"`
|
||||
SQLText string `gorm:"column:sql_text"`
|
||||
Checksum string `gorm:"column:checksum"`
|
||||
MinAppVersion string `gorm:"column:min_app_version"`
|
||||
OrderNo int `gorm:"column:order_no"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
}
|
||||
|
||||
func listActiveClientMigrations(db *gorm.DB) ([]clientLocalMigration, error) {
|
||||
if strings.EqualFold(db.Dialector.Name(), "sqlite") {
|
||||
return []clientLocalMigration{}, nil
|
||||
}
|
||||
if err := ensureClientMigrationRegistryTable(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows := make([]clientLocalMigration, 0)
|
||||
if err := db.Raw(`
|
||||
SELECT id, name, sql_text, checksum, COALESCE(min_app_version, '') AS min_app_version, order_no, created_at
|
||||
FROM qt_client_local_migrations
|
||||
WHERE is_active = 1
|
||||
ORDER BY order_no ASC, created_at ASC, id ASC
|
||||
`).Scan(&rows).Error; err != nil {
|
||||
return nil, fmt.Errorf("load client local migrations: %w", err)
|
||||
}
|
||||
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func ensureClientMigrationRegistryTable(db *gorm.DB) error {
|
||||
// Check if table exists instead of trying to create (avoids permission issues)
|
||||
if !tableExists(db, "qt_client_local_migrations") {
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS qt_client_local_migrations (
|
||||
id VARCHAR(128) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
sql_text LONGTEXT NOT NULL,
|
||||
checksum VARCHAR(128) NOT NULL,
|
||||
min_app_version VARCHAR(64) NULL,
|
||||
order_no INT NOT NULL DEFAULT 0,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
INDEX idx_qt_client_local_migrations_active_order (is_active, order_no, created_at)
|
||||
)
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("create qt_client_local_migrations table: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !tableExists(db, "qt_client_schema_state") {
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS qt_client_schema_state (
|
||||
username VARCHAR(100) NOT NULL,
|
||||
last_applied_migration_id VARCHAR(128) NULL,
|
||||
app_version VARCHAR(64) NULL,
|
||||
last_checked_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (username),
|
||||
INDEX idx_qt_client_schema_state_checked (last_checked_at)
|
||||
)
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("create qt_client_schema_state table: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func tableExists(db *gorm.DB, tableName string) bool {
|
||||
var count int64
|
||||
// For MariaDB/MySQL, check information_schema
|
||||
if err := db.Raw(`
|
||||
SELECT COUNT(*) FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?
|
||||
`, tableName).Scan(&count).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
return count > 0
|
||||
}
|
||||
|
||||
func (s *Service) applyMissingRemoteMigrations(migrations []clientLocalMigration) error {
|
||||
for i := range migrations {
|
||||
m := migrations[i]
|
||||
computedChecksum := digestSQL(m.SQLText)
|
||||
checksum := strings.TrimSpace(m.Checksum)
|
||||
if checksum == "" {
|
||||
checksum = computedChecksum
|
||||
} else if !strings.EqualFold(checksum, computedChecksum) {
|
||||
return fmt.Errorf("checksum mismatch for migration %s", m.ID)
|
||||
}
|
||||
|
||||
applied, err := s.localDB.GetRemoteMigrationApplied(m.ID)
|
||||
if err == nil {
|
||||
if strings.TrimSpace(applied.Checksum) != checksum {
|
||||
return fmt.Errorf("checksum mismatch for migration %s", m.ID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fmt.Errorf("check local applied migration %s: %w", m.ID, err)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(m.SQLText) == "" {
|
||||
if err := s.localDB.UpsertRemoteMigrationApplied(m.ID, checksum, appmeta.Version(), time.Now().UTC()); err != nil {
|
||||
return fmt.Errorf("mark empty migration %s as applied: %w", m.ID, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
statements := splitSQLStatementsLite(m.SQLText)
|
||||
if err := s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
||||
for _, stmt := range statements {
|
||||
if err := tx.Exec(stmt).Error; err != nil {
|
||||
return fmt.Errorf("apply migration %s statement %q: %w", m.ID, stmt, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.localDB.UpsertRemoteMigrationApplied(m.ID, checksum, appmeta.Version(), time.Now().UTC()); err != nil {
|
||||
return fmt.Errorf("record applied migration %s: %w", m.ID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitSQLStatementsLite(script string) []string {
|
||||
scanner := bufio.NewScanner(strings.NewReader(script))
|
||||
scanner.Buffer(make([]byte, 1024), 1024*1024)
|
||||
|
||||
lines := make([]string, 0, 64)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "--") {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, scanner.Text())
|
||||
}
|
||||
combined := strings.Join(lines, "\n")
|
||||
raw := strings.Split(combined, ";")
|
||||
stmts := make([]string, 0, len(raw))
|
||||
for _, stmt := range raw {
|
||||
trimmed := strings.TrimSpace(stmt)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
stmts = append(stmts, trimmed)
|
||||
}
|
||||
return stmts
|
||||
}
|
||||
|
||||
func digestSQL(sqlText string) string {
|
||||
hash := sha256.Sum256([]byte(sqlText))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
func compareVersions(left, right string) int {
|
||||
leftParts := normalizeVersionParts(left)
|
||||
rightParts := normalizeVersionParts(right)
|
||||
maxLen := len(leftParts)
|
||||
if len(rightParts) > maxLen {
|
||||
maxLen = len(rightParts)
|
||||
}
|
||||
for i := 0; i < maxLen; i++ {
|
||||
lv := 0
|
||||
rv := 0
|
||||
if i < len(leftParts) {
|
||||
lv = leftParts[i]
|
||||
}
|
||||
if i < len(rightParts) {
|
||||
rv = rightParts[i]
|
||||
}
|
||||
if lv < rv {
|
||||
return -1
|
||||
}
|
||||
if lv > rv {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time) error {
|
||||
if strings.EqualFold(mariaDB.Dialector.Name(), "sqlite") {
|
||||
return nil
|
||||
}
|
||||
username := strings.TrimSpace(s.localDB.GetDBUser())
|
||||
if username == "" {
|
||||
return nil
|
||||
}
|
||||
lastMigrationID := ""
|
||||
if id, err := s.localDB.GetLatestAppliedRemoteMigrationID(); err == nil {
|
||||
lastMigrationID = id
|
||||
}
|
||||
return mariaDB.Exec(`
|
||||
INSERT INTO qt_client_schema_state (username, last_applied_migration_id, app_version, last_checked_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
last_applied_migration_id = VALUES(last_applied_migration_id),
|
||||
app_version = VALUES(app_version),
|
||||
last_checked_at = VALUES(last_checked_at),
|
||||
updated_at = VALUES(updated_at)
|
||||
`, username, lastMigrationID, appmeta.Version(), checkedAt, checkedAt).Error
|
||||
}
|
||||
|
||||
func normalizeVersionParts(v string) []int {
|
||||
trimmed := strings.TrimSpace(v)
|
||||
trimmed = strings.TrimPrefix(trimmed, "v")
|
||||
chunks := strings.Split(trimmed, ".")
|
||||
parts := make([]int, 0, len(chunks))
|
||||
for _, chunk := range chunks {
|
||||
clean := strings.TrimSpace(chunk)
|
||||
if clean == "" {
|
||||
parts = append(parts, 0)
|
||||
continue
|
||||
}
|
||||
n := 0
|
||||
for i := 0; i < len(clean); i++ {
|
||||
if clean[i] < '0' || clean[i] > '9' {
|
||||
clean = clean[:i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if clean != "" {
|
||||
if parsed, err := strconv.Atoi(clean); err == nil {
|
||||
n = parsed
|
||||
}
|
||||
}
|
||||
parts = append(parts, n)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func toReadinessFromState(state *localdb.LocalSyncGuardState) *SyncReadiness {
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
blocked := state.Status == ReadinessBlocked
|
||||
return &SyncReadiness{
|
||||
Status: state.Status,
|
||||
Blocked: blocked,
|
||||
ReasonCode: state.ReasonCode,
|
||||
ReasonText: state.ReasonText,
|
||||
RequiredMinAppVersion: state.RequiredMinAppVersion,
|
||||
LastCheckedAt: state.LastCheckedAt,
|
||||
}
|
||||
}
|
||||
@@ -145,6 +145,9 @@ func (s *Service) ImportConfigurationsToLocal() (*ConfigImportResult, error) {
|
||||
|
||||
if existing != nil && err == nil {
|
||||
localCfg.ID = existing.ID
|
||||
if localCfg.Line <= 0 && existing.Line > 0 {
|
||||
localCfg.Line = existing.Line
|
||||
}
|
||||
result.Updated++
|
||||
} else {
|
||||
result.Imported++
|
||||
@@ -200,6 +203,7 @@ func (s *Service) ImportProjectsToLocal() (*ProjectImportResult, error) {
|
||||
}
|
||||
|
||||
existing.OwnerUsername = project.OwnerUsername
|
||||
existing.Code = project.Code
|
||||
existing.Name = project.Name
|
||||
existing.TrackerURL = project.TrackerURL
|
||||
existing.IsActive = project.IsActive
|
||||
@@ -340,18 +344,24 @@ 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 latestEstimateLocalID uint
|
||||
var latestEstimateCreatedAt time.Time
|
||||
for _, pl := range serverPricelists {
|
||||
// Check if pricelist already exists locally
|
||||
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
|
||||
if existing != nil {
|
||||
// Track latest estimate pricelist by created_at for component refresh.
|
||||
if pl.Source == string(models.PricelistSourceEstimate) && (latestEstimateCreatedAt.IsZero() || pl.CreatedAt.After(latestEstimateCreatedAt)) {
|
||||
latestEstimateCreatedAt = pl.CreatedAt
|
||||
latestEstimateLocalID = existing.ID
|
||||
// Backfill items for legacy/partial local caches where only pricelist metadata exists.
|
||||
if s.localDB.CountLocalPricelistItems(existing.ID) == 0 {
|
||||
itemCount, err := s.SyncPricelistItems(existing.ID)
|
||||
if err != nil {
|
||||
slog.Warn("failed to sync missing pricelist items for existing local pricelist", "version", pl.Version, "error", err)
|
||||
} else {
|
||||
slog.Info("synced missing pricelist items for existing local pricelist", "version", pl.Version, "items", itemCount)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -381,23 +391,19 @@ func (s *Service) SyncPricelists() (int, error) {
|
||||
slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount)
|
||||
}
|
||||
|
||||
if pl.Source == string(models.PricelistSourceEstimate) && (latestEstimateCreatedAt.IsZero() || pl.CreatedAt.After(latestEstimateCreatedAt)) {
|
||||
latestEstimateCreatedAt = pl.CreatedAt
|
||||
latestEstimateLocalID = localPL.ID
|
||||
}
|
||||
synced++
|
||||
}
|
||||
|
||||
// Update component prices from latest estimate pricelist only.
|
||||
if latestEstimateLocalID > 0 {
|
||||
updated, err := s.localDB.UpdateComponentPricesFromPricelist(latestEstimateLocalID)
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// 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()
|
||||
@@ -406,6 +412,83 @@ func (s *Service) SyncPricelists() (int, error) {
|
||||
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() {
|
||||
@@ -542,24 +625,34 @@ func (s *Service) listConnectedDBUsers(mariaDB *gorm.DB) (map[string]struct{}, e
|
||||
}
|
||||
|
||||
func ensureUserSyncStatusTable(db *gorm.DB) error {
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS qt_pricelist_sync_status (
|
||||
username VARCHAR(100) NOT NULL,
|
||||
last_sync_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
app_version VARCHAR(64) NULL,
|
||||
PRIMARY KEY (username),
|
||||
INDEX idx_qt_pricelist_sync_status_last_sync (last_sync_at)
|
||||
)
|
||||
`).Error; err != nil {
|
||||
return err
|
||||
// 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.
|
||||
return db.Exec(`
|
||||
ALTER TABLE qt_pricelist_sync_status
|
||||
ADD COLUMN IF NOT EXISTS app_version VARCHAR(64) NULL
|
||||
`).Error
|
||||
// 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
|
||||
@@ -595,15 +688,7 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
|
||||
// Convert and save locally
|
||||
localItems := make([]localdb.LocalPricelistItem, len(serverItems))
|
||||
for i, item := range serverItems {
|
||||
partnumbers := make(localdb.LocalStringList, 0, len(item.Partnumbers))
|
||||
partnumbers = append(partnumbers, item.Partnumbers...)
|
||||
localItems[i] = localdb.LocalPricelistItem{
|
||||
PricelistID: localPricelistID,
|
||||
LotName: item.LotName,
|
||||
Price: item.Price,
|
||||
AvailableQty: item.AvailableQty,
|
||||
Partnumbers: partnumbers,
|
||||
}
|
||||
localItems[i] = *localdb.PricelistItemToLocal(&item, localPricelistID)
|
||||
}
|
||||
|
||||
if err := s.localDB.SaveLocalPricelistItems(localItems); err != nil {
|
||||
@@ -776,11 +861,36 @@ func (s *Service) pushProjectChange(change *localdb.PendingChange) error {
|
||||
projectRepo := repository.NewProjectRepository(mariaDB)
|
||||
project := payload.Snapshot
|
||||
project.UUID = payload.ProjectUUID
|
||||
|
||||
if err := projectRepo.UpsertByUUID(&project); err != nil {
|
||||
return fmt.Errorf("upsert project on server: %w", err)
|
||||
if strings.TrimSpace(project.Code) == "" {
|
||||
project.Code = strings.TrimSpace(derefString(project.Name))
|
||||
if project.Code == "" {
|
||||
project.Code = project.UUID
|
||||
}
|
||||
}
|
||||
|
||||
// Try upsert by UUID first
|
||||
err = projectRepo.UpsertByUUID(&project)
|
||||
if err != nil {
|
||||
// Check if it's a duplicate (code, variant) constraint violation
|
||||
// In this case, find existing project with same (code, variant) and link to it
|
||||
var existing models.Project
|
||||
lookupErr := mariaDB.Where("code = ? AND variant = ?", project.Code, project.Variant).First(&existing).Error
|
||||
if lookupErr == nil {
|
||||
// Found duplicate - link local project to existing server project
|
||||
slog.Info("project duplicate found, linking to existing",
|
||||
"local_uuid", project.UUID,
|
||||
"server_uuid", existing.UUID,
|
||||
"server_id", existing.ID,
|
||||
"code", project.Code,
|
||||
"variant", project.Variant)
|
||||
project.ID = existing.ID
|
||||
} else {
|
||||
// Not a duplicate issue, return original error
|
||||
return fmt.Errorf("upsert project on server: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update local project with server ID
|
||||
localProject, localErr := s.localDB.GetProjectByUUID(project.UUID)
|
||||
if localErr == nil {
|
||||
if project.ID > 0 {
|
||||
@@ -796,6 +906,17 @@ func (s *Service) pushProjectChange(change *localdb.PendingChange) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func derefString(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func ptrString(value string) *string {
|
||||
return &value
|
||||
}
|
||||
|
||||
func decodeProjectChangePayload(change *localdb.PendingChange) (ProjectChangePayload, error) {
|
||||
var payload ProjectChangePayload
|
||||
if err := json.Unmarshal([]byte(change.Payload), &payload); err == nil && payload.ProjectUUID != "" {
|
||||
@@ -1066,7 +1187,8 @@ func (s *Service) ensureConfigurationProject(mariaDB *gorm.DB, cfg *models.Confi
|
||||
systemProject = &models.Project{
|
||||
UUID: uuid.NewString(),
|
||||
OwnerUsername: "",
|
||||
Name: "Без проекта",
|
||||
Code: "Без проекта",
|
||||
Name: ptrString("Без проекта"),
|
||||
IsActive: true,
|
||||
IsSystem: true,
|
||||
}
|
||||
@@ -1230,6 +1352,21 @@ func (s *Service) loadCurrentConfigurationState(configurationUUID string) (model
|
||||
}
|
||||
}
|
||||
|
||||
if currentVersionNo == 0 {
|
||||
if err := s.repairMissingConfigurationVersion(localCfg); err != nil {
|
||||
return models.Configuration{}, "", 0, fmt.Errorf("repair missing configuration version: %w", err)
|
||||
}
|
||||
var latest localdb.LocalConfigurationVersion
|
||||
err = s.localDB.DB().
|
||||
Where("configuration_uuid = ?", configurationUUID).
|
||||
Order("version_no DESC").
|
||||
First(&latest).Error
|
||||
if err == nil {
|
||||
currentVersionNo = latest.VersionNo
|
||||
currentVersionID = latest.ID
|
||||
}
|
||||
}
|
||||
|
||||
if currentVersionNo == 0 {
|
||||
return models.Configuration{}, "", 0, fmt.Errorf("no local configuration version found for %s", configurationUUID)
|
||||
}
|
||||
@@ -1237,6 +1374,64 @@ func (s *Service) loadCurrentConfigurationState(configurationUUID string) (model
|
||||
return cfg, currentVersionID, currentVersionNo, nil
|
||||
}
|
||||
|
||||
func (s *Service) repairMissingConfigurationVersion(localCfg *localdb.LocalConfiguration) error {
|
||||
if localCfg == nil {
|
||||
return fmt.Errorf("local configuration is nil")
|
||||
}
|
||||
|
||||
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
||||
var cfg localdb.LocalConfiguration
|
||||
if err := tx.Where("uuid = ?", localCfg.UUID).First(&cfg).Error; err != nil {
|
||||
return fmt.Errorf("load local configuration: %w", err)
|
||||
}
|
||||
|
||||
// If versions exist, just make sure current_version_id is set.
|
||||
var latest localdb.LocalConfigurationVersion
|
||||
if err := tx.Where("configuration_uuid = ?", cfg.UUID).
|
||||
Order("version_no DESC").
|
||||
First(&latest).Error; err == nil {
|
||||
if cfg.CurrentVersionID == nil || *cfg.CurrentVersionID == "" {
|
||||
if err := tx.Model(&localdb.LocalConfiguration{}).
|
||||
Where("uuid = ?", cfg.UUID).
|
||||
Update("current_version_id", latest.ID).Error; err != nil {
|
||||
return fmt.Errorf("set current version id: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fmt.Errorf("load latest version: %w", err)
|
||||
}
|
||||
|
||||
snapshot, err := localdb.BuildConfigurationSnapshot(&cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build configuration snapshot: %w", err)
|
||||
}
|
||||
|
||||
note := "Auto-repaired missing local version"
|
||||
version := localdb.LocalConfigurationVersion{
|
||||
ID: uuid.NewString(),
|
||||
ConfigurationUUID: cfg.UUID,
|
||||
VersionNo: 1,
|
||||
Data: snapshot,
|
||||
ChangeNote: ¬e,
|
||||
AppVersion: appmeta.Version(),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := tx.Create(&version).Error; err != nil {
|
||||
return fmt.Errorf("create initial version: %w", err)
|
||||
}
|
||||
if err := tx.Model(&localdb.LocalConfiguration{}).
|
||||
Where("uuid = ?", cfg.UUID).
|
||||
Update("current_version_id", version.ID).Error; err != nil {
|
||||
return fmt.Errorf("set current version id: %w", err)
|
||||
}
|
||||
|
||||
slog.Warn("repaired missing local configuration version", "uuid", cfg.UUID, "version_no", version.VersionNo)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// NOTE: prepared for future conflict resolution:
|
||||
// when server starts storing version metadata, we can compare payload.CurrentVersionNo
|
||||
// against remote version and branch into custom strategies. For now use last-write-wins.
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
85
internal/services/sync/service_pricelist_cleanup_test.go
Normal file
85
internal/services/sync/service_pricelist_cleanup_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package sync_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||
)
|
||||
|
||||
func TestSyncPricelistsDeletesMissingUnusedLocalPricelists(t *testing.T) {
|
||||
local := newLocalDBForSyncTest(t)
|
||||
serverDB := newServerDBForSyncTest(t)
|
||||
if err := serverDB.AutoMigrate(&models.Pricelist{}, &models.PricelistItem{}); err != nil {
|
||||
t.Fatalf("migrate server pricelist tables: %v", err)
|
||||
}
|
||||
|
||||
serverPL := models.Pricelist{
|
||||
Source: "estimate",
|
||||
Version: "2026-01-01-001",
|
||||
Notification: "server",
|
||||
CreatedBy: "tester",
|
||||
IsActive: true,
|
||||
CreatedAt: time.Now().Add(-1 * time.Hour),
|
||||
}
|
||||
if err := serverDB.Create(&serverPL).Error; err != nil {
|
||||
t.Fatalf("create server pricelist: %v", err)
|
||||
}
|
||||
if err := serverDB.Create(&models.PricelistItem{PricelistID: serverPL.ID, LotName: "CPU_A", Price: 10}).Error; err != nil {
|
||||
t.Fatalf("create server pricelist item: %v", err)
|
||||
}
|
||||
|
||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||
ServerID: 9991,
|
||||
Source: "estimate",
|
||||
Version: "old-unused",
|
||||
Name: "old-unused",
|
||||
CreatedAt: time.Now().Add(-2 * time.Hour),
|
||||
SyncedAt: time.Now().Add(-2 * time.Hour),
|
||||
IsUsed: false,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed local missing pricelist: %v", err)
|
||||
}
|
||||
missingUsed := &localdb.LocalPricelist{
|
||||
ServerID: 9992,
|
||||
Source: "estimate",
|
||||
Version: "old-used",
|
||||
Name: "old-used",
|
||||
CreatedAt: time.Now().Add(-2 * time.Hour),
|
||||
SyncedAt: time.Now().Add(-2 * time.Hour),
|
||||
IsUsed: false,
|
||||
}
|
||||
if err := local.SaveLocalPricelist(missingUsed); err != nil {
|
||||
t.Fatalf("seed local referenced pricelist: %v", err)
|
||||
}
|
||||
if err := local.SaveConfiguration(&localdb.LocalConfiguration{
|
||||
UUID: "cfg-1",
|
||||
OriginalUsername: "tester",
|
||||
Name: "cfg",
|
||||
Items: localdb.LocalConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1}},
|
||||
IsActive: true,
|
||||
PricelistID: &missingUsed.ServerID,
|
||||
SyncStatus: "synced",
|
||||
CreatedAt: time.Now().Add(-30 * time.Minute),
|
||||
UpdatedAt: time.Now().Add(-30 * time.Minute),
|
||||
}); err != nil {
|
||||
t.Fatalf("seed local configuration with pricelist ref: %v", err)
|
||||
}
|
||||
|
||||
svc := syncsvc.NewServiceWithDB(serverDB, local)
|
||||
if _, err := svc.SyncPricelists(); err != nil {
|
||||
t.Fatalf("sync pricelists: %v", err)
|
||||
}
|
||||
|
||||
if _, err := local.GetLocalPricelistByServerID(9991); err == nil {
|
||||
t.Fatalf("expected unused missing local pricelist to be deleted")
|
||||
}
|
||||
if _, err := local.GetLocalPricelistByServerID(9992); err != nil {
|
||||
t.Fatalf("expected local pricelist referenced by active config to stay: %v", err)
|
||||
}
|
||||
if _, err := local.GetLocalPricelistByServerID(serverPL.ID); err != nil {
|
||||
t.Fatalf("expected server pricelist to be synced locally: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ func TestPushPendingChangesProjectsBeforeConfigurations(t *testing.T) {
|
||||
projectService := services.NewProjectService(local)
|
||||
configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false })
|
||||
|
||||
project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: "Project A"})
|
||||
project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: ptrString("Project A"), Code: "PRJ-A"})
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
@@ -74,11 +74,11 @@ func TestPushPendingChangesProjectCreateThenUpdateBeforeFirstPush(t *testing.T)
|
||||
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"})
|
||||
project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: ptrString("Project v1"), Code: "PRJ-V1"})
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
if _, err := projectService.Update(project.UUID, "tester", &services.UpdateProjectRequest{Name: "Project v2"}); err != nil {
|
||||
if _, err := projectService.Update(project.UUID, "tester", &services.UpdateProjectRequest{Name: ptrString("Project v2")}); err != nil {
|
||||
t.Fatalf("update project: %v", err)
|
||||
}
|
||||
|
||||
@@ -100,8 +100,8 @@ func TestPushPendingChangesProjectCreateThenUpdateBeforeFirstPush(t *testing.T)
|
||||
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)
|
||||
if serverProject.Name == nil || *serverProject.Name != "Project v2" {
|
||||
t.Fatalf("expected latest project name, got %v", serverProject.Name)
|
||||
}
|
||||
|
||||
var serverCfg models.Configuration
|
||||
@@ -250,6 +250,71 @@ func TestPushPendingChangesCreateIsIdempotent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushPendingChangesConfigurationPushesLine(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 Line Push",
|
||||
Items: models.ConfigItems{{LotName: "CPU_LINE", Quantity: 1, UnitPrice: 1000}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
if created.Line != 10 {
|
||||
t.Fatalf("expected local create line=10, got %d", created.Line)
|
||||
}
|
||||
|
||||
if _, err := pushService.PushPendingChanges(); err != nil {
|
||||
t.Fatalf("push pending changes: %v", err)
|
||||
}
|
||||
|
||||
var serverCfg models.Configuration
|
||||
if err := serverDB.Where("uuid = ?", created.UUID).First(&serverCfg).Error; err != nil {
|
||||
t.Fatalf("load server config: %v", err)
|
||||
}
|
||||
if serverCfg.Line != 10 {
|
||||
t.Fatalf("expected server line=10 after push, got %d", serverCfg.Line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportConfigurationsToLocalPullsLine(t *testing.T) {
|
||||
local := newLocalDBForSyncTest(t)
|
||||
serverDB := newServerDBForSyncTest(t)
|
||||
|
||||
cfg := models.Configuration{
|
||||
UUID: "server-line-config",
|
||||
OwnerUsername: "tester",
|
||||
Name: "Cfg Line Pull",
|
||||
Items: models.ConfigItems{{LotName: "CPU_PULL", Quantity: 1, UnitPrice: 900}},
|
||||
ServerCount: 1,
|
||||
Line: 40,
|
||||
}
|
||||
total := cfg.Items.Total()
|
||||
cfg.TotalPrice = &total
|
||||
if err := serverDB.Create(&cfg).Error; err != nil {
|
||||
t.Fatalf("seed server config: %v", err)
|
||||
}
|
||||
|
||||
svc := syncsvc.NewServiceWithDB(serverDB, local)
|
||||
if _, err := svc.ImportConfigurationsToLocal(); err != nil {
|
||||
t.Fatalf("import configurations to local: %v", err)
|
||||
}
|
||||
|
||||
localCfg, err := local.GetConfigurationByUUID(cfg.UUID)
|
||||
if err != nil {
|
||||
t.Fatalf("load local config: %v", err)
|
||||
}
|
||||
if localCfg.Line != 40 {
|
||||
t.Fatalf("expected imported line=40, got %d", localCfg.Line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushPendingChangesCreateThenUpdateBeforeFirstPush(t *testing.T) {
|
||||
local := newLocalDBForSyncTest(t)
|
||||
serverDB := newServerDBForSyncTest(t)
|
||||
@@ -324,6 +389,8 @@ CREATE TABLE qt_projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
owner_username TEXT NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
variant TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL,
|
||||
tracker_url TEXT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
@@ -333,6 +400,9 @@ CREATE TABLE qt_projects (
|
||||
);`).Error; err != nil {
|
||||
t.Fatalf("create qt_projects: %v", err)
|
||||
}
|
||||
if err := db.Exec(`CREATE UNIQUE INDEX idx_qt_projects_code_variant ON qt_projects(code, variant);`).Error; err != nil {
|
||||
t.Fatalf("create qt_projects index: %v", err)
|
||||
}
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE qt_configurations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -348,11 +418,15 @@ 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,
|
||||
line_no INTEGER NULL,
|
||||
price_updated_at DATETIME NULL,
|
||||
created_at DATETIME
|
||||
);`).Error; err != nil {
|
||||
@@ -361,6 +435,10 @@ CREATE TABLE qt_configurations (
|
||||
return db
|
||||
}
|
||||
|
||||
func ptrString(value string) *string {
|
||||
return &value
|
||||
}
|
||||
|
||||
func getCurrentVersionInfo(t *testing.T, local *localdb.LocalDB, configurationUUID string, currentVersionID *string) (int, string) {
|
||||
t.Helper()
|
||||
if currentVersionID == nil || *currentVersionID == "" {
|
||||
|
||||
93
markdown
Normal file
93
markdown
Normal file
@@ -0,0 +1,93 @@
|
||||
<p><strong>ТЕХНИЧЕСКОЕ ЗАДАНИЕ</strong></p>
|
||||
<p>1. Требования к продукции</p>
|
||||
<p>Поставляемое оборудование должно быть новым, оригинальным, не бывшим
|
||||
в употреблении, не восстановленным. Гарантийный срок — не менее 12
|
||||
месяцев с момента поставки. Все компоненты, включая процессоры, память,
|
||||
накопители и контроллеры, должны быть совместимы и предварительно
|
||||
протестированы на совместимость и производительность в рамках единой
|
||||
системы.</p>
|
||||
<p>2. Количественные и качественные характеристики</p>
|
||||
<p>2.1. Базовые требования к серверной платформе:</p>
|
||||
<p>Модель (артикул): Сервер в конфигурации с шасси, поддерживающим 12
|
||||
отсека 2.5" для накопителей NVMe U.2/U.3.</p>
|
||||
<p>Форм-фактор: 2U для установки в стойку.</p>
|
||||
<p>Кол-во процессорных сокетов: 1.</p>
|
||||
<p>2.2. Требования к процессорам (CPU):</p>
|
||||
<p>Количество: 1 шт.</p>
|
||||
<p>Модель/семейство: 256t x AMD EPYC 9755 2.7 GHz 128c-Core Processor
|
||||
Объём кэша L3: 512MB Техпроцесс: 4 нм, архитектура процессора: Zen-5
|
||||
(Turin).</p>
|
||||
<p>Минимальная базовая тактовая частота: 2.7 ГГц.</p>
|
||||
<p>Максимальная частота работы процессора (Turboboost): 4.1 GHz</p>
|
||||
<p>Для обеспечения полной производительности всех 12 накопителей NVMe и
|
||||
сетевых адаптеров, процессор и системная платформа в целом должны
|
||||
обеспечивать достаточно линий PCIe 5.0.</p>
|
||||
<p>2.3. Требования к оперативной памяти (RAM):</p>
|
||||
<p>Тип памяти: DDR5 с коррекцией ошибок (ECC) RDIMM 6000Mhz.</p>
|
||||
<p>Минимальный объем оперативной памяти: 2048 ГБ.</p>
|
||||
<p>Конфигурация: Модули памяти должны быть установлены в оптимальной
|
||||
конфигурации для обеспечения полной пропускной способности всех линий
|
||||
PCIe 5.0 от NVMe-накопителей. Платформа должна поддерживать установку не
|
||||
менее 16 модулей DDR5 ECC REG 6000Mhz для последующего
|
||||
масштабирования.</p>
|
||||
<p>Поддерживаемая частота: Не менее 6000 МТ/с.</p>
|
||||
<p>Одобренные модули оперативной памяти - Samsung/Micron/Hynix, DDR5,
|
||||
64GB, RDIMM, ECC</p>
|
||||
<p>2.4 Требования к системе хранения данных:</p>
|
||||
<p>Конфигурация шасси: Обязательна поставка в конфигурации с 12 отсеками
|
||||
2.5" под горячую замену, поддерживающими интерфейс NVMe через PCIe 5.0
|
||||
x4 и 2 отсеками 2.5"/М.2 под горячую замену, поддерживающими SATA.</p>
|
||||
<p>Дополнительно (для ОС): Поддержка установки 2x M.2 NVMe накопителей в
|
||||
dedicated-слотах на материнской плате под операционную систему отдельно
|
||||
от основного хранилища данных.</p>
|
||||
<p>2.5. Требования к сетевым интерфейсам (NIC):</p>
|
||||
<p>Слоты расширения сети: Наличие не менее 1 слотов OCP 3.0 SFF для
|
||||
установки специализированных сетевых адаптеров.</p>
|
||||
<p>Дополнительные сетевые адаптеры (обязательная поставка): Один сетевой
|
||||
адаптер OCP 3.0 с 2 портами 25 Гбит/с Ethernet Intel 810 или Mellanox
|
||||
CX-6.</p>
|
||||
<p>Встроенные порты управления: порт 1 Гбит/с Ethernet (RJ-45) для
|
||||
модуля управления iBMC.</p>
|
||||
<p>2.6. Требования к интерфейсам расширения (PCIe):</p>
|
||||
<p>Количество слотов PCIe: Конфигурация с 12 дисками NVMe использует
|
||||
большую часть линий PCIe. Тем не менее, не менее слотов PCIe 5.0 должны
|
||||
оставаться свободными для будущего расширения (например, установки
|
||||
дополнительных сетевых карт или FPGA/GPU для специфических задач).</p>
|
||||
<p>Шинная архитектура: Поставщик должен предоставить схему распределения
|
||||
линий PCIe 5.0 между процессором контроллером RAID и слотами расширения,
|
||||
подтверждающую отсутствие узких мест (bottlenecks).</p>
|
||||
<p>2.7. Требования к системе управления:</p>
|
||||
<p>Внеполосный (out-of-band) модуль управления: Наличие выделенного чипа
|
||||
iBMC.</p>
|
||||
<p>Интеллектуальные функции: Критически важна поддержка детального
|
||||
мониторинга состояния NVMe-накопителей (SMART, температура, износ,
|
||||
прогнозирование сбоев) через интерфейс iBMC и поддержка технологии
|
||||
горячей замены NVMe-накопителей.</p>
|
||||
<p>Наличие безагентного КВМ (HTML5)</p>
|
||||
<p>Желательна поддержка shared LAN (через NCSI OCPv3 разъем) с
|
||||
тегированием VLAN, настройка по умолчанию DHCP IPv4</p>
|
||||
<p>Управление параметрами работы сервера (режим работы вентиляторов,
|
||||
потребление энергии, итд).</p>
|
||||
<p>Наличие 2х видов логирования:</p>
|
||||
<p>Все аппаратные события, включая ECC ошибки по памяти, ошибки PCIe,
|
||||
SATA (IPMI/Hardware Event Log)</p>
|
||||
<p>Все сессии аутентификации и изменения системных параметров
|
||||
(Audit/Security Log)</p>
|
||||
<p>Наличие функционала обновления прошивок сервера (BIOS, BMC, CPLD
|
||||
(опционально)) с сохранением и без сохранения настроек.</p>
|
||||
<p>2.8. Требования к системе питания и охлаждения:</p>
|
||||
<p>Блоки питания (PSU):</p>
|
||||
<p>Количество: 2 шт. (резервирование 1+1) с возможностью горячей
|
||||
замены.</p>
|
||||
<p>Номинальная мощность каждого: Минимум 1200 Вт с сертификацией 80 Plus
|
||||
Platinum/Titanium. Мощность должна быть достаточной для одновременной
|
||||
пиковой нагрузки от процессора, 12 NVMe-дисков и прочих компонентов.</p>
|
||||
<p>Система охлаждения: не менее N+1 резервирование вентиляторов.</p>
|
||||
<p>3. Упаковка и маркировка:</p>
|
||||
<p>Оборудование должно быть упаковано так, чтобы предотвратить
|
||||
повреждение при транспортировке.</p>
|
||||
<p>4. Требования к комплектации:</p>
|
||||
<p>Рельсовый комплект - без инструментов с горизонтальной загрузкой</p>
|
||||
<p>Оборудование - C19-C20 или С13-С14 кабели питания 1-2 m в зависимости
|
||||
от БП, 19 " комплект для монтажа в стойку, комплект винтов, все отсеки с
|
||||
корзинами.</p>
|
||||
41
memory.md
Normal file
41
memory.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Changes summary (2026-02-11)
|
||||
|
||||
Implemented strict `lot_category` flow using `pricelist_items.lot_category` only (no parsing from `lot_name`), plus local caching and backfill:
|
||||
|
||||
1. Local DB schema + migrations
|
||||
- Added `lot_category` column to `local_pricelist_items` via `LocalPricelistItem` model.
|
||||
- Added local migration `2026_02_11_local_pricelist_item_category` to add the column if missing and create indexes:
|
||||
- `idx_local_pricelist_items_pricelist_lot (pricelist_id, lot_name)`
|
||||
- `idx_local_pricelist_items_lot_category (lot_category)`
|
||||
|
||||
2. Server model/repository
|
||||
- Added `LotCategory` field to `models.PricelistItem`.
|
||||
- `PricelistRepository.GetItems` now sets `Category` from `LotCategory` (no parsing from `lot_name`).
|
||||
|
||||
3. Sync + local DB helpers
|
||||
- `SyncPricelistItems` now saves `lot_category` into local cache via `PricelistItemToLocal`.
|
||||
- Added `LocalDB.CountLocalPricelistItemsWithEmptyCategory` and `LocalDB.ReplaceLocalPricelistItems`.
|
||||
- Added `LocalDB.GetLocalLotCategoriesByServerPricelistID` for strict category lookup.
|
||||
- Added `SyncPricelists` backfill step: for used active pricelists with empty categories, force refresh items from server.
|
||||
|
||||
4. API handler
|
||||
- `GET /api/pricelists/:id/items` returns `category` from `local_pricelist_items.lot_category` (no parsing from `lot_name`).
|
||||
|
||||
5. Article category foundation
|
||||
- New package `internal/article`:
|
||||
- `ResolveLotCategoriesStrict` pulls categories from local pricelist items and errors on missing category.
|
||||
- `GroupForLotCategory` maps only allowed codes (CPU/MEM/GPU/M2/SSD/HDD/EDSFF/HHHL/NIC/HCA/DPU/PSU/PS) to article groups; excludes `SFP`.
|
||||
- Error type `MissingCategoryForLotError` with base `ErrMissingCategoryForLot`.
|
||||
|
||||
6. Tests
|
||||
- Added unit tests for converters and article category resolver.
|
||||
- Added handler test to ensure `/api/pricelists/:id/items` returns `lot_category`.
|
||||
- Added sync test for category backfill on used pricelist items.
|
||||
- `go test ./...` passed.
|
||||
|
||||
Additional fixes (2026-02-11):
|
||||
- Fixed article parsing bug: CPU/GPU parsers were swapped in `internal/article/generator.go`. CPU now uses last token from CPU lot; GPU uses model+memory from `GPU_vendor_model_mem_iface`.
|
||||
- Adjusted configurator base tab layout to align labels on the same row (separate label row + input row grid).
|
||||
|
||||
UI rule (2026-02-19):
|
||||
- In all breadcrumbs, truncate long specification/configuration names to 16 characters using ellipsis.
|
||||
2
migrations/022_add_article_to_configurations.sql
Normal file
2
migrations/022_add_article_to_configurations.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE qt_configurations
|
||||
ADD COLUMN IF NOT EXISTS article VARCHAR(80) NULL AFTER server_count;
|
||||
2
migrations/023_add_server_model_to_configurations.sql
Normal file
2
migrations/023_add_server_model_to_configurations.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE qt_configurations
|
||||
ADD COLUMN IF NOT EXISTS server_model VARCHAR(100) NULL AFTER server_count;
|
||||
2
migrations/024_add_support_code_to_configurations.sql
Normal file
2
migrations/024_add_support_code_to_configurations.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE qt_configurations
|
||||
ADD COLUMN IF NOT EXISTS support_code VARCHAR(20) NULL AFTER server_model;
|
||||
38
migrations/025_add_project_code.sql
Normal file
38
migrations/025_add_project_code.sql
Normal file
@@ -0,0 +1,38 @@
|
||||
-- Add project code and enforce uniqueness
|
||||
|
||||
ALTER TABLE qt_projects
|
||||
ADD COLUMN code VARCHAR(100) NULL AFTER owner_username;
|
||||
|
||||
-- Copy code from current project name (truncate to fit)
|
||||
UPDATE qt_projects
|
||||
SET code = LEFT(TRIM(COALESCE(name, '')), 100);
|
||||
|
||||
-- Fallback for any remaining blanks
|
||||
UPDATE qt_projects
|
||||
SET code = uuid
|
||||
WHERE code IS NULL OR TRIM(code) = '';
|
||||
|
||||
-- Drop unique index if it already exists to allow de-duplication updates
|
||||
DROP INDEX IF EXISTS idx_qt_projects_code ON qt_projects;
|
||||
|
||||
-- De-duplicate codes: OPS-1948-2, OPS-1948-3... (MariaDB without CTE)
|
||||
UPDATE qt_projects p
|
||||
JOIN (
|
||||
SELECT p1.id,
|
||||
p1.code AS base_code,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM qt_projects p2
|
||||
WHERE p2.code = p1.code AND p2.id <= p1.id
|
||||
) AS rn
|
||||
FROM qt_projects p1
|
||||
) r ON r.id = p.id
|
||||
SET p.code = CASE
|
||||
WHEN r.rn = 1 THEN r.base_code
|
||||
ELSE CONCAT(LEFT(r.base_code, 90), '-', r.rn)
|
||||
END;
|
||||
|
||||
ALTER TABLE qt_projects
|
||||
MODIFY COLUMN code VARCHAR(100) NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX idx_qt_projects_code ON qt_projects(code);
|
||||
28
migrations/026_add_project_variant.sql
Normal file
28
migrations/026_add_project_variant.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
-- Add project variant and reset codes from project names
|
||||
|
||||
ALTER TABLE qt_projects
|
||||
ADD COLUMN variant VARCHAR(100) NOT NULL DEFAULT '' AFTER code;
|
||||
|
||||
-- Drop legacy unique index on code to allow duplicate codes
|
||||
DROP INDEX IF EXISTS idx_qt_projects_code ON qt_projects;
|
||||
DROP INDEX IF EXISTS idx_qt_projects_code_variant ON qt_projects;
|
||||
|
||||
-- Reset code from name and clear variant
|
||||
UPDATE qt_projects
|
||||
SET code = LEFT(TRIM(COALESCE(name, '')), 100),
|
||||
variant = '';
|
||||
|
||||
-- De-duplicate by assigning variant numbers: -2, -3...
|
||||
UPDATE qt_projects p
|
||||
JOIN (
|
||||
SELECT p1.id,
|
||||
p1.code,
|
||||
(SELECT COUNT(*)
|
||||
FROM qt_projects p2
|
||||
WHERE p2.code = p1.code AND p2.id <= p1.id) AS rn
|
||||
FROM qt_projects p1
|
||||
) r ON r.id = p.id
|
||||
SET p.code = r.code,
|
||||
p.variant = CASE WHEN r.rn = 1 THEN '' ELSE CONCAT('-', r.rn) END;
|
||||
|
||||
CREATE UNIQUE INDEX idx_qt_projects_code_variant ON qt_projects(code, variant);
|
||||
4
migrations/027_project_name_nullable.sql
Normal file
4
migrations/027_project_name_nullable.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Allow NULL project names
|
||||
|
||||
ALTER TABLE qt_projects
|
||||
MODIFY COLUMN name VARCHAR(200) NULL;
|
||||
18
migrations/028_add_line_no_to_configurations.sql
Normal file
18
migrations/028_add_line_no_to_configurations.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
ALTER TABLE qt_configurations
|
||||
ADD COLUMN IF NOT EXISTS line_no INT NULL AFTER only_in_stock;
|
||||
|
||||
UPDATE qt_configurations q
|
||||
JOIN (
|
||||
SELECT
|
||||
id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY COALESCE(NULLIF(TRIM(project_uuid), ''), '__NO_PROJECT__')
|
||||
ORDER BY created_at ASC, id ASC
|
||||
) AS rn
|
||||
FROM qt_configurations
|
||||
WHERE line_no IS NULL OR line_no <= 0
|
||||
) ranked ON ranked.id = q.id
|
||||
SET q.line_no = ranked.rn * 10;
|
||||
|
||||
ALTER TABLE qt_configurations
|
||||
ADD INDEX IF NOT EXISTS idx_qt_configurations_project_line_no (project_uuid, line_no);
|
||||
72
releases/memory/v1.2.1.md
Normal file
72
releases/memory/v1.2.1.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# 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.
|
||||
59
releases/memory/v1.2.2.md
Normal file
59
releases/memory/v1.2.2.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# 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.
|
||||
95
releases/memory/v1.2.3.md
Normal file
95
releases/memory/v1.2.3.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# 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
|
||||
68
releases/memory/v1.3.0.md
Normal file
68
releases/memory/v1.3.0.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Release v1.3.0 (2026-02-11)
|
||||
|
||||
## Summary
|
||||
|
||||
Introduced article generation with pricelist categories, added local configuration storage, and expanded sync/export capabilities. Simplified article generator compression and loosened project update constraints.
|
||||
|
||||
## Changes
|
||||
|
||||
### Main Features: Articles + Pricelist Categories
|
||||
|
||||
- **Article generation pipeline**
|
||||
- New generator and tests under `internal/article/`
|
||||
- Category support with test coverage
|
||||
- **Pricelist category integration**
|
||||
- Handler and repository updates
|
||||
- Sync backfill test for category propagation
|
||||
|
||||
### Local Configuration Storage
|
||||
|
||||
- **Local DB support**
|
||||
- New localdb models, converters, snapshots, and migrations
|
||||
- Local configuration service for cached configurations
|
||||
|
||||
### Export & UI
|
||||
|
||||
- **Export handler updates** for article data output
|
||||
- **Configs and index templates** adjusted for new article-related fields
|
||||
|
||||
### Behavior Changes
|
||||
|
||||
- **Cross-user project updates allowed**
|
||||
- Removed restriction in project service
|
||||
- **Article compression refinement**
|
||||
- Generator logic simplified to reduce complexity
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
None identified. Existing APIs remain intact.
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. `internal/article/*` - Article generator + categories + tests
|
||||
2. `internal/localdb/*` - Local DB models, migrations, snapshots
|
||||
3. `internal/handlers/export.go` - Export updates
|
||||
4. `internal/handlers/pricelist.go` - Category handling
|
||||
5. `internal/services/sync/service.go` - Category backfill logic
|
||||
6. `web/templates/configs.html` - Article field updates
|
||||
7. `web/templates/index.html` - Article field updates
|
||||
|
||||
**Stats:** 33 files changed, 2059 insertions(+), 329 deletions(-)
|
||||
|
||||
## Commits
|
||||
|
||||
- `5edffe8` - Add article generation and pricelist categories
|
||||
- `e355903` - Allow cross-user project updates
|
||||
- `e58fd35` - Refine article compression and simplify generator
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Tests not run (not requested)
|
||||
|
||||
## Migration Notes
|
||||
|
||||
- New migrations:
|
||||
- `022_add_article_to_configurations.sql`
|
||||
- `023_add_server_model_to_configurations.sql`
|
||||
- `024_add_support_code_to_configurations.sql`
|
||||
- Ensure migrations are applied before running v1.3.0
|
||||
66
releases/memory/v1.3.2.md
Normal file
66
releases/memory/v1.3.2.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Release v1.3.2 (2026-02-19)
|
||||
|
||||
## Summary
|
||||
|
||||
Release focuses on stability and data integrity for local configurations. Added configuration revision history, stronger recovery for broken local sync/version states, improved sync self-healing, and clearer API error logging.
|
||||
|
||||
## Changes
|
||||
|
||||
### Configuration Revisions
|
||||
|
||||
- Added full local configuration revision flow with storage and UI support.
|
||||
- Introduced revisions page/template and backend plumbing for browsing revisions.
|
||||
- Prevented duplicate revisions when content did not actually change.
|
||||
|
||||
### Local Data Integrity and Recovery
|
||||
|
||||
- Added migration and snapshot support for local configuration version data.
|
||||
- Hardened updates for legacy/orphaned configuration rows:
|
||||
- allow update when project UUID is unchanged even if referenced project is missing locally;
|
||||
- recover gracefully when `current_version_id` is stale or version rows are missing.
|
||||
- Added regression tests for orphan-project and missing-current-version scenarios.
|
||||
|
||||
### Sync Reliability
|
||||
|
||||
- Added smart self-healing path for sync errors.
|
||||
- Fixed duplicate-project sync edge cases.
|
||||
|
||||
### API and Logging
|
||||
|
||||
- Improved HTTP error mapping for configuration updates (`404/403` instead of generic `500` in known cases).
|
||||
- Enhanced request logger to capture error responses (status, response body snippet, gin errors) for failed requests.
|
||||
|
||||
### UI and Export
|
||||
|
||||
- Updated project detail and index templates for revisions and related UX improvements.
|
||||
- Updated export pipeline and tests to align with revisions/project behavior changes.
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
None identified.
|
||||
|
||||
## Files Changed
|
||||
|
||||
- 24 files changed, 2394 insertions(+), 482 deletions(-)
|
||||
- Main touched areas:
|
||||
- `internal/services/local_configuration.go`
|
||||
- `internal/services/local_configuration_versioning_test.go`
|
||||
- `internal/localdb/{localdb.go,migrations.go,snapshots.go,local_migrations_test.go}`
|
||||
- `internal/services/export.go`
|
||||
- `cmd/qfs/main.go`
|
||||
- `web/templates/{config_revisions.html,project_detail.html,index.html,base.html}`
|
||||
|
||||
## Commits Included (`v1.3.1..v1.3.2`)
|
||||
|
||||
- `b153afb` - Add smart self-healing for sync errors
|
||||
- `8508ee2` - Fix sync errors for duplicate projects and add modal scrolling
|
||||
- `2e973b6` - Add configuration revisions system and project variant deletion
|
||||
- `71f73e2` - chore: save current changes
|
||||
- `cbaeafa` - Deduplicate configuration revisions and update revisions UI
|
||||
- `075fc70` - Harden local config updates and error logging
|
||||
|
||||
## Testing
|
||||
|
||||
- [x] Targeted tests for local configuration update/version recovery:
|
||||
- `go test ./internal/services -run 'TestUpdateNoAuth(AllowsOrphanProjectWhenUUIDUnchanged|RecoversWhenCurrentVersionMissing|KeepsProjectWhenProjectUUIDOmitted)$'`
|
||||
- [ ] Full regression suite not run in this release step.
|
||||
@@ -1,51 +0,0 @@
|
||||
# QuoteForge v1.0.3
|
||||
|
||||
Дата релиза: 2026-02-06
|
||||
Тег: `v1.0.3`
|
||||
Диапазон изменений: `v1.0.2..v1.0.3`
|
||||
|
||||
## Что нового
|
||||
|
||||
- Добавлена страница управления проектами `/projects` с:
|
||||
- датой и временем создания проекта;
|
||||
- сортировкой по названию и дате создания;
|
||||
- серверной пагинацией;
|
||||
- фильтром по автору в заголовке таблицы.
|
||||
- Добавлена отдельная вкладка `Статус синхронизации` на уровне `Алерты / Компоненты / Прайслисты`.
|
||||
- Во вкладке статуса синхронизации отображаются:
|
||||
- пользователь;
|
||||
- версия приложения;
|
||||
- статус (`онлайн` или относительное время последней синхронизации).
|
||||
|
||||
## Изменения синхронизации
|
||||
|
||||
- Реализован heartbeat синхронизации пользователей в MariaDB: `qt_pricelist_sync_status`.
|
||||
- Добавлен API `GET /api/sync/users-status` для UI статуса синхронизации.
|
||||
- Логика онлайн-статуса рассчитана от интервала фоновой синхронизации: `5 минут + 10%`.
|
||||
- В heartbeat фиксируется версия приложения (`app_version`).
|
||||
|
||||
## Важные исправления
|
||||
|
||||
- Исправлено восстановление отсутствующей серверной конфигурации при push обновлений.
|
||||
- Исправлено экранирование паролей в MySQL DSN в setup.
|
||||
- Улучшена логика запуска SQL-миграций на старте при отсутствии прав/необходимости.
|
||||
- Обновлена логика пересчета прайслистов через админский price-refresh.
|
||||
|
||||
## Миграции и совместимость
|
||||
|
||||
Добавлены SQL-миграции:
|
||||
|
||||
- `migrations/010_add_pricelist_sync_status.sql`
|
||||
- `migrations/011_add_app_version_to_pricelist_sync_status.sql`
|
||||
|
||||
Релиз совместим с предыдущей веткой `v1.0.x`; новая таблица синхронизации создается автоматически.
|
||||
|
||||
## Коммиты в релизе
|
||||
|
||||
- `b1b50ce` Add projects table controls and sync status tab with app version
|
||||
- `6ab1e98` sync: recover missing server config during update push
|
||||
- `a1d2192` Fix MySQL DSN escaping for setup passwords and clarify DB user setup
|
||||
- `a90c07c` update stale files list
|
||||
- `e9307c4` Apply remaining pricelist and local-first updates
|
||||
- `1b48401` Use admin price-refresh logic for pricelist recalculation
|
||||
- `4a86f7b` fix: skip startup sql migrations when not needed or no permissions
|
||||
89
releases/v1.2.1/RELEASE_NOTES.md
Normal file
89
releases/v1.2.1/RELEASE_NOTES.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# 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*
|
||||
56
scripts/check-secrets.sh
Executable file
56
scripts/check-secrets.sh
Executable file
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if ! git rev-parse --git-dir >/dev/null 2>&1; then
|
||||
echo "Not inside a git repository."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v rg >/dev/null 2>&1; then
|
||||
echo "ripgrep (rg) is required for secret scanning."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
staged_files=()
|
||||
while IFS= read -r file; do
|
||||
staged_files+=("$file")
|
||||
done < <(git diff --cached --name-only --diff-filter=ACMRTUXB)
|
||||
|
||||
if [ "${#staged_files[@]}" -eq 0 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
secret_pattern='AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{35}|-----BEGIN (RSA|OPENSSH|EC|DSA|PRIVATE) KEY-----|(?i)(password|passwd|pwd|secret|token|api[_-]?key|jwt_secret)\s*[:=]\s*["'"'"'][^"'"'"'\s]{8,}["'"'"']'
|
||||
allow_pattern='CHANGE_ME|REDACTED|PLACEHOLDER|EXAMPLE|example|<[^>]+>'
|
||||
|
||||
found=0
|
||||
|
||||
for file in "${staged_files[@]}"; do
|
||||
case "$file" in
|
||||
dist/*|*.png|*.jpg|*.jpeg|*.gif|*.webp|*.pdf|*.zip|*.gz|*.exe|*.dll|*.so|*.dylib)
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
|
||||
if ! content="$(git show ":$file" 2>/dev/null)"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
hits="$(printf '%s' "$content" | rg -n --no-heading -e "$secret_pattern" || true)"
|
||||
if [ -n "$hits" ]; then
|
||||
filtered="$(printf '%s\n' "$hits" | rg -v -e "$allow_pattern" || true)"
|
||||
if [ -n "$filtered" ]; then
|
||||
echo "Potential secret found in staged file: $file"
|
||||
printf '%s\n' "$filtered"
|
||||
found=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$found" -ne 0 ]; then
|
||||
echo
|
||||
echo "Commit blocked: remove or redact secrets before committing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
78
todo.md
Normal file
78
todo.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# QuoteForge — План очистки (удаление admin pricing)
|
||||
|
||||
Цель: убрать всё, что связано с администрированием цен, складскими справками, алертами.
|
||||
Оставить: конфигуратор, проекты, read-only просмотр прайслистов, sync, offline-first.
|
||||
|
||||
---
|
||||
|
||||
## 1. Удалить файлы
|
||||
|
||||
- [x] `internal/handlers/pricing.go` (40.6KB) — весь admin pricing UI
|
||||
- [x] `internal/services/pricing/` — весь пакет расчёта цен
|
||||
- [x] `internal/services/pricelist/` — весь пакет управления прайслистами
|
||||
- [x] `internal/services/stock_import.go` — импорт складских справок
|
||||
- [x] `internal/services/alerts/` — весь пакет алертов
|
||||
- [x] `internal/warehouse/` — алгоритмы расчёта цен по складу
|
||||
- [x] `web/templates/admin_pricing.html` (109KB) — страница admin pricing
|
||||
- [x] `cmd/cron/` — cron jobs (cleanup-pricelists, update-prices, update-popularity)
|
||||
- [x] `cmd/importer/` — утилита импорта данных
|
||||
|
||||
## 2. Упростить `internal/handlers/pricelist.go` (read-only)
|
||||
|
||||
Read-only методы (List, Get, GetItems, GetLotNames, GetLatest) уже работают
|
||||
только через `h.localDB` (SQLite) без `pricelist.Service`.
|
||||
|
||||
- [x] Убрать поле `service *pricelist.Service` из структуры `PricelistHandler`
|
||||
- [x] Изменить конструктор: `NewPricelistHandler(localDB *localdb.LocalDB)`
|
||||
- [x] Удалить write-методы: `Create()`, `CreateWithProgress()`, `Delete()`, `SetActive()`, `CanWrite()`
|
||||
- [x] Удалить метод `refreshLocalPricelistCacheFromServer()` (зависит от service)
|
||||
- [x] Удалить import `pricelist` пакета
|
||||
- [x] Оставить: `List()`, `Get()`, `GetItems()`, `GetLotNames()`, `GetLatest()`
|
||||
|
||||
## 3. Упростить `cmd/qfs/main.go`
|
||||
|
||||
- [x] Удалить создание сервисов: `pricingService`, `alertService`, `pricelistService`, `stockImportService`
|
||||
- [x] Удалить хэндлер: `pricingHandler`
|
||||
- [x] Изменить создание `pricelistHandler`: `NewPricelistHandler(local)` (без service)
|
||||
- [x] Удалить repositories: `priceRepo`, `alertRepo` (statsRepo оставить — nil-safe)
|
||||
- [x] Удалить все routes `/api/admin/pricing/*` (строки ~1407-1430)
|
||||
- [x] Из `/api/pricelists/*` оставить только read-only:
|
||||
- `GET ""` (List), `GET "/latest"`, `GET "/:id"`, `GET "/:id/items"`, `GET "/:id/lots"`
|
||||
- [x] Удалить write routes: `POST ""`, `POST "/create-with-progress"`, `PATCH "/:id/active"`, `DELETE "/:id"`, `GET "/can-write"`
|
||||
- [x] Удалить web page `/admin/pricing`
|
||||
- [x] Исправить `/pricelists` — вместо redirect на admin/pricing сделать страницу
|
||||
- [x] В `QuoteService` конструкторе: передавать `nil` для `pricingService`
|
||||
- [x] Удалить imports: `pricing`, `pricelist`, `alerts` пакеты
|
||||
|
||||
## 4. Упростить `handlers/web.go`
|
||||
|
||||
- [x] Удалить из `simplePages`: `admin_pricing.html`
|
||||
- [x] Удалить метод: `AdminPricing()`
|
||||
- [x] Оставить все остальные методы включая `Pricelists()` и `PricelistDetail()`
|
||||
|
||||
## 5. Упростить `base.html` (навигация)
|
||||
|
||||
- [x] Убрать ссылку "Администратор цен"
|
||||
- [x] Добавить ссылку "Прайслисты" (на `/pricelists`)
|
||||
- [x] Оставить: "Мои проекты", "Прайслисты", sync indicator
|
||||
|
||||
## 6. Sync — оставить полностью
|
||||
|
||||
- Background worker: pull компоненты + прайслисты, push конфигурации
|
||||
- Все `/api/sync/*` endpoints остаются
|
||||
- Это ядро offline-first архитектуры
|
||||
|
||||
## 7. Верификация
|
||||
|
||||
- [x] `go build ./cmd/qfs` — компилируется
|
||||
- [x] `go vet ./...` — без ошибок
|
||||
- [ ] Запуск → `/configs` работает
|
||||
- [ ] `/pricelists` — read-only список работает
|
||||
- [ ] `/pricelists/:id` — детали работают
|
||||
- [ ] Sync с сервером работает
|
||||
- [ ] Нет ссылок на admin pricing в UI
|
||||
|
||||
## 8. Обновить CLAUDE.md
|
||||
- [x] Убрать разделы про admin pricing, stock import, alerts, cron
|
||||
- [x] Обновить API endpoints список
|
||||
- [x] Обновить описание приложения
|
||||
@@ -45,10 +45,10 @@
|
||||
<div id="toast" class="fixed bottom-4 right-4 z-50"></div>
|
||||
|
||||
<!-- Sync Info Modal -->
|
||||
<div id="sync-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
|
||||
<div class="bg-white rounded-lg shadow-xl max-w-lg w-full mx-4">
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div id="sync-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden p-4">
|
||||
<div class="bg-white rounded-lg shadow-xl max-w-lg w-full max-h-[90vh] flex flex-col">
|
||||
<div class="p-6 border-b border-gray-200 flex-shrink-0">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-lg font-medium text-gray-900">Информация о синхронизации</h3>
|
||||
<button onclick="closeSyncModal()" class="text-gray-400 hover:text-gray-500">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -56,8 +56,10 @@
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-5">
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div class="p-6 space-y-5">
|
||||
<!-- Section 1: DB Connection -->
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900 mb-2">Подключение к БД</h4>
|
||||
@@ -123,9 +125,31 @@
|
||||
<h4 class="font-medium text-gray-900 mb-2">Ошибки синхронизации</h4>
|
||||
<div id="modal-errors-list" class="text-sm max-h-40 overflow-y-auto space-y-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<!-- Section 5: Self-Healing (shown only if errors exist) -->
|
||||
<div id="modal-repair-section" class="hidden">
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h4 class="font-medium text-blue-900 mb-2">Автоматическое исправление</h4>
|
||||
<p class="text-sm text-blue-700 mb-3">
|
||||
Система может исправить данные и очистить ошибки синхронизации:
|
||||
</p>
|
||||
<ul class="text-sm text-blue-700 mb-3 ml-4 list-disc space-y-1">
|
||||
<li>Проверит и исправит названия проектов</li>
|
||||
<li>Восстановит битые ссылки на проекты</li>
|
||||
<li>Очистит ошибки и даст pending changes еще шанс</li>
|
||||
</ul>
|
||||
<button id="repair-button" onclick="repairPendingChanges()"
|
||||
class="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
ИСПРАВИТЬ
|
||||
</button>
|
||||
<div id="repair-result" class="mt-2 text-sm hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 border-t border-gray-200 flex-shrink-0">
|
||||
<div class="flex justify-end">
|
||||
<button onclick="closeSyncModal()" class="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700">
|
||||
Закрыть
|
||||
</button>
|
||||
@@ -235,7 +259,8 @@
|
||||
// Section 4: Errors
|
||||
const errorsSection = document.getElementById('modal-errors-section');
|
||||
const errorsList = document.getElementById('modal-errors-list');
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
const hasErrors = data.errors && data.errors.length > 0;
|
||||
if (hasErrors) {
|
||||
errorsSection.classList.remove('hidden');
|
||||
errorsList.innerHTML = data.errors.map(error => {
|
||||
const time = new Date(error.timestamp).toLocaleString('ru-RU');
|
||||
@@ -246,12 +271,65 @@
|
||||
} else {
|
||||
errorsSection.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Section 5: Repair (show only if errors exist)
|
||||
const repairSection = document.getElementById('modal-repair-section');
|
||||
const repairResult = document.getElementById('repair-result');
|
||||
if (hasErrors) {
|
||||
repairSection.classList.remove('hidden');
|
||||
repairResult.classList.add('hidden');
|
||||
repairResult.innerHTML = '';
|
||||
} else {
|
||||
repairSection.classList.add('hidden');
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('Failed to load sync info:', e);
|
||||
document.getElementById('modal-db-status').textContent = 'Ошибка загрузки';
|
||||
}
|
||||
}
|
||||
|
||||
// Repair pending changes
|
||||
async function repairPendingChanges() {
|
||||
const button = document.getElementById('repair-button');
|
||||
const resultDiv = document.getElementById('repair-result');
|
||||
|
||||
button.disabled = true;
|
||||
button.textContent = 'Исправление...';
|
||||
resultDiv.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/sync/repair', { method: 'POST' });
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.success) {
|
||||
resultDiv.classList.remove('hidden');
|
||||
if (data.repaired > 0) {
|
||||
resultDiv.className = 'mt-2 text-sm text-green-700 bg-green-50 rounded px-3 py-2';
|
||||
resultDiv.textContent = `✓ Исправлено: ${data.repaired}`;
|
||||
// Reload sync info after repair
|
||||
setTimeout(() => loadSyncInfo(), 1000);
|
||||
} else {
|
||||
resultDiv.className = 'mt-2 text-sm text-yellow-700 bg-yellow-50 rounded px-3 py-2';
|
||||
resultDiv.textContent = 'Нечего исправлять или проблемы остались';
|
||||
if (data.remaining_errors && data.remaining_errors.length > 0) {
|
||||
resultDiv.innerHTML += '<div class="mt-1 text-xs">' + data.remaining_errors.join('<br>') + '</div>';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
resultDiv.classList.remove('hidden');
|
||||
resultDiv.className = 'mt-2 text-sm text-red-700 bg-red-50 rounded px-3 py-2';
|
||||
resultDiv.textContent = 'Ошибка: ' + (data.error || 'неизвестная ошибка');
|
||||
}
|
||||
} catch (e) {
|
||||
resultDiv.classList.remove('hidden');
|
||||
resultDiv.className = 'mt-2 text-sm text-red-700 bg-red-50 rounded px-3 py-2';
|
||||
resultDiv.textContent = 'Ошибка запроса: ' + e.message;
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = 'ИСПРАВИТЬ';
|
||||
}
|
||||
}
|
||||
|
||||
// Event delegation for sync dropdown and actions
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadDBUser();
|
||||
@@ -285,6 +363,14 @@
|
||||
showToast(successMessage, 'success');
|
||||
// Update last sync time - removed since dropdown is gone
|
||||
// loadLastSyncTime();
|
||||
|
||||
// Dispatch custom event for pages to react to sync completion
|
||||
window.dispatchEvent(new CustomEvent('sync-completed', {
|
||||
detail: {
|
||||
endpoint: endpoint,
|
||||
data: data
|
||||
}
|
||||
}));
|
||||
} else if (resp.status === 423) {
|
||||
const reason = data.reason_text || data.error || 'Синхронизация заблокирована.';
|
||||
showToast(reason, 'error');
|
||||
|
||||
238
web/templates/config_revisions.html
Normal file
238
web/templates/config_revisions.html
Normal file
@@ -0,0 +1,238 @@
|
||||
{{define "title"}}Ревизии - QuoteForge{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/projects" class="text-gray-500 hover:text-gray-700" title="Все проекты">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9.75L12 3l9 6.75v9A2.25 2.25 0 0118.75 21h-13.5A2.25 2.25 0 013 18.75v-9z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 21v-6h6v6"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<div class="text-2xl font-bold flex items-center gap-2" id="revisions-breadcrumbs">
|
||||
<a id="breadcrumb-code-link" href="/projects" class="text-blue-700 hover:underline">
|
||||
<span id="breadcrumb-code">—</span>
|
||||
</a>
|
||||
<span class="text-gray-400">-</span>
|
||||
<a id="breadcrumb-variant-link" href="/projects" class="text-blue-700 hover:underline">
|
||||
<span id="breadcrumb-variant">main</span>
|
||||
</a>
|
||||
<span class="text-gray-400">-</span>
|
||||
<a id="breadcrumb-config-link" href="/configurator" class="text-blue-700 hover:underline">
|
||||
<span id="breadcrumb-config">—</span>
|
||||
</a>
|
||||
<span class="text-gray-400">-</span>
|
||||
<span class="text-gray-600">Ревизии</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="revisions-list">
|
||||
<div class="text-center py-8 text-gray-500">Загрузка...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const configUUID = '{{.ConfigUUID}}';
|
||||
let configData = null;
|
||||
let projectData = null;
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text || '';
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function formatMoney(value) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return '—';
|
||||
return '$\u00A0' + num.toLocaleString('ru-RU', {minimumFractionDigits: 0, maximumFractionDigits: 2});
|
||||
}
|
||||
|
||||
function parseVersionSnapshot(version) {
|
||||
try {
|
||||
const raw = typeof version.data === 'string' ? version.data : '';
|
||||
if (!raw) return { article: '—', price: null, serverCount: 1 };
|
||||
const parsed = JSON.parse(raw);
|
||||
return {
|
||||
article: parsed.article || '—',
|
||||
price: typeof parsed.total_price === 'number' ? parsed.total_price : null,
|
||||
serverCount: Number.isFinite(Number(parsed.server_count)) && Number(parsed.server_count) > 0
|
||||
? Number(parsed.server_count)
|
||||
: 1
|
||||
};
|
||||
} catch (_) {
|
||||
return { article: '—', price: null, serverCount: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
function truncateBreadcrumbSpecName(name) {
|
||||
const maxLength = 16;
|
||||
if (!name || name.length <= maxLength) return name;
|
||||
return name.slice(0, maxLength - 1) + '…';
|
||||
}
|
||||
|
||||
async function loadConfigInfo() {
|
||||
try {
|
||||
const resp = await fetch('/api/configs/' + configUUID);
|
||||
if (!resp.ok) {
|
||||
document.getElementById('revisions-list').innerHTML =
|
||||
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Конфигурация не найдена</div>';
|
||||
return false;
|
||||
}
|
||||
configData = await resp.json();
|
||||
|
||||
const fullConfigName = configData.name || 'Конфигурация';
|
||||
const configBreadcrumbEl = document.getElementById('breadcrumb-config');
|
||||
configBreadcrumbEl.textContent = truncateBreadcrumbSpecName(fullConfigName);
|
||||
configBreadcrumbEl.title = fullConfigName;
|
||||
document.getElementById('breadcrumb-config-link').href = '/configurator?uuid=' + configUUID;
|
||||
|
||||
if (configData.project_uuid) {
|
||||
const projResp = await fetch('/api/projects/' + configData.project_uuid);
|
||||
if (projResp.ok) {
|
||||
projectData = await projResp.json();
|
||||
document.getElementById('breadcrumb-code').textContent = projectData.code || '—';
|
||||
const variant = (projectData.variant || '').trim();
|
||||
document.getElementById('breadcrumb-variant').textContent = variant === '' ? 'main' : variant;
|
||||
document.getElementById('breadcrumb-variant-link').href = '/projects/' + projectData.uuid;
|
||||
|
||||
// Find main variant for code link
|
||||
const allResp = await fetch('/api/projects/all');
|
||||
if (allResp.ok) {
|
||||
const allProjects = await allResp.json();
|
||||
const main = (Array.isArray(allProjects) ? allProjects : []).find(
|
||||
p => p.code === projectData.code && (p.variant || '').trim() === ''
|
||||
);
|
||||
if (main) {
|
||||
document.getElementById('breadcrumb-code-link').href = '/projects/' + main.uuid;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
document.getElementById('revisions-list').innerHTML =
|
||||
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки</div>';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVersions() {
|
||||
try {
|
||||
const resp = await fetch('/api/configs/' + configUUID + '/versions?limit=200');
|
||||
if (!resp.ok) {
|
||||
document.getElementById('revisions-list').innerHTML =
|
||||
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки ревизий</div>';
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
const versions = data.versions || [];
|
||||
renderVersions(versions);
|
||||
} catch (e) {
|
||||
document.getElementById('revisions-list').innerHTML =
|
||||
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderVersions(versions) {
|
||||
if (versions.length === 0) {
|
||||
document.getElementById('revisions-list').innerHTML =
|
||||
'<div class="bg-white rounded-lg shadow p-8 text-center text-gray-500">Нет ревизий</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="bg-white rounded-lg shadow overflow-hidden"><table class="w-full">';
|
||||
html += '<thead class="bg-gray-50"><tr>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Версия</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Артикул</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Цена</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Серверов</th>';
|
||||
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
|
||||
html += '</tr></thead><tbody class="divide-y">';
|
||||
|
||||
versions.forEach((v, idx) => {
|
||||
const date = new Date(v.created_at).toLocaleString('ru-RU');
|
||||
const author = v.created_by || '—';
|
||||
const snapshot = parseVersionSnapshot(v);
|
||||
const isCurrent = idx === 0;
|
||||
|
||||
html += '<tr class="hover:bg-gray-50' + (isCurrent ? ' bg-blue-50' : '') + '">';
|
||||
html += '<td class="px-4 py-3 text-sm font-medium">';
|
||||
html += 'v' + v.version_no;
|
||||
if (isCurrent) html += ' <span class="text-xs text-blue-600 font-normal">(текущая)</span>';
|
||||
html += '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(date) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(author) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(snapshot.article) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + formatMoney(snapshot.price) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(String(snapshot.serverCount)) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-right space-x-2">';
|
||||
|
||||
// Open in configurator (readonly view)
|
||||
html += '<a href="/configurator?uuid=' + configUUID + '&version=' + v.version_no + '" class="text-blue-600 hover:text-blue-800 inline-block" title="Открыть">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg></a>';
|
||||
|
||||
// Clone from this version
|
||||
html += '<button onclick="cloneFromVersion(' + v.version_no + ')" class="text-green-600 hover:text-green-800" title="Скопировать из этой ревизии">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg></button>';
|
||||
|
||||
// Rollback (not for current version)
|
||||
if (!isCurrent) {
|
||||
html += '<button onclick="rollbackToVersion(' + v.version_no + ')" class="text-orange-600 hover:text-orange-800" title="Восстановить эту ревизию">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"></path></svg></button>';
|
||||
}
|
||||
|
||||
html += '</td></tr>';
|
||||
});
|
||||
|
||||
html += '</tbody></table></div>';
|
||||
document.getElementById('revisions-list').innerHTML = html;
|
||||
}
|
||||
|
||||
async function cloneFromVersion(versionNo) {
|
||||
const name = prompt('Название копии:', (configData ? configData.name : '') + ' (v' + versionNo + ')');
|
||||
if (!name) return;
|
||||
const resp = await fetch('/api/configs/' + configUUID + '/clone', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name: name, from_version: versionNo})
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
alert(data.error || 'Не удалось скопировать');
|
||||
return;
|
||||
}
|
||||
const created = await resp.json();
|
||||
showToast('Копия создана', 'success');
|
||||
if (created && created.uuid) {
|
||||
window.location.href = '/configurator?uuid=' + created.uuid;
|
||||
}
|
||||
}
|
||||
|
||||
async function rollbackToVersion(versionNo) {
|
||||
if (!confirm('Восстановить конфигурацию до ревизии v' + versionNo + '?')) return;
|
||||
const resp = await fetch('/api/configs/' + configUUID + '/rollback', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({target_version: versionNo})
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
alert(data.error || 'Не удалось восстановить');
|
||||
return;
|
||||
}
|
||||
showToast('Ревизия восстановлена', 'success');
|
||||
loadVersions();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
const ok = await loadConfigInfo();
|
||||
if (!ok) return;
|
||||
await loadVersions();
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{template "base" .}}
|
||||
@@ -4,13 +4,10 @@
|
||||
<div class="space-y-4">
|
||||
<h1 class="text-2xl font-bold">Мои конфигурации</h1>
|
||||
|
||||
<div id="action-buttons" class="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<button onclick="openCreateModal()" class="py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
|
||||
<div id="action-buttons" class="mt-4">
|
||||
<button onclick="openCreateModal()" class="w-full sm:w-auto py-3 px-6 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
|
||||
+ Создать новую конфигурацию
|
||||
</button>
|
||||
<button id="import-configs-btn" onclick="importConfigsFromServer()" class="py-3 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 font-medium">
|
||||
Импорт с сервера
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 inline-flex rounded-lg border border-gray-200 overflow-hidden">
|
||||
@@ -57,15 +54,15 @@
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Номер Opportunity</label>
|
||||
<input type="text" id="opportunity-number" placeholder="Например: OPP-2024-001"
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Название конфигурации</label>
|
||||
<input type="text" id="opportunity-number" placeholder="Например: Сервер для проекта X"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Проект</label>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Код проекта</label>
|
||||
<input id="create-project-input"
|
||||
list="create-project-options"
|
||||
placeholder="Начните вводить название проекта"
|
||||
placeholder="Например: OPS-123 (Lenovo)"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<datalist id="create-project-options"></datalist>
|
||||
<div class="mt-2 flex justify-between items-center gap-3">
|
||||
@@ -150,7 +147,7 @@
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Проект</label>
|
||||
<input id="move-project-input"
|
||||
list="move-project-options"
|
||||
placeholder="Начните вводить название проекта"
|
||||
placeholder="Например: OPS-123 (Lenovo)"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<datalist id="move-project-options"></datalist>
|
||||
<div class="mt-2 flex justify-between items-center gap-3">
|
||||
@@ -177,7 +174,17 @@
|
||||
<div id="create-project-on-move-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||
<h2 class="text-xl font-semibold mb-3">Проект не найден</h2>
|
||||
<p class="text-sm text-gray-600 mb-4">Проект "<span id="create-project-on-move-name" class="font-medium text-gray-900"></span>" не найден. <span id="create-project-on-move-description">Создать и привязать квоту?</span></p>
|
||||
<p class="text-sm text-gray-600 mb-4">Проект с кодом "<span id="create-project-on-move-code" class="font-medium text-gray-900"></span>" не найден. <span id="create-project-on-move-description">Создать и привязать квоту?</span></p>
|
||||
<div class="mb-4">
|
||||
<label for="create-project-on-move-name" class="block text-sm font-medium text-gray-700 mb-1">Название проекта</label>
|
||||
<input id="create-project-on-move-name" type="text" placeholder="Например: Инфраструктура для OPS-123"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="create-project-on-move-variant" class="block text-sm font-medium text-gray-700 mb-1">Вариант (необязательно)</label>
|
||||
<input id="create-project-on-move-variant" type="text" placeholder="Например: Lenovo"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button onclick="closeCreateProjectOnMoveModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
||||
<button id="create-project-on-move-confirm-btn" onclick="confirmCreateProjectOnMove()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Создать и привязать</button>
|
||||
@@ -194,10 +201,12 @@ let configStatusMode = 'active';
|
||||
let configsSearch = '';
|
||||
let projectsCache = [];
|
||||
let projectNameByUUID = {};
|
||||
let projectCodeByUUID = {};
|
||||
let projectVariantByUUID = {};
|
||||
let pendingMoveConfigUUID = '';
|
||||
let pendingMoveProjectName = '';
|
||||
let pendingMoveProjectCode = '';
|
||||
let pendingCreateConfigName = '';
|
||||
let pendingCreateProjectName = '';
|
||||
let pendingCreateProjectCode = '';
|
||||
|
||||
function renderConfigs(configs) {
|
||||
const emptyText = configStatusMode === 'archived'
|
||||
@@ -252,10 +261,23 @@ function renderConfigs(configs) {
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-700">' + escapeHtml(projectName) + '</td>';
|
||||
}
|
||||
}
|
||||
const article = c.article ? escapeHtml(c.article) : '';
|
||||
const serverModel = c.server_model ? escapeHtml(c.server_model) : '';
|
||||
const subtitle = article || serverModel;
|
||||
if (configStatusMode === 'archived') {
|
||||
html += '<td class="px-4 py-3 text-sm font-medium text-gray-700">' + escapeHtml(c.name) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm font-medium text-gray-700">';
|
||||
html += '<div>' + escapeHtml(c.name) + '</div>';
|
||||
if (subtitle) {
|
||||
html += '<div class="text-xs text-gray-500">' + subtitle + '</div>';
|
||||
}
|
||||
html += '</td>';
|
||||
} else {
|
||||
html += '<td class="px-4 py-3 text-sm font-medium"><a href="/configurator?uuid=' + c.uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(c.name) + '</a></td>';
|
||||
html += '<td class="px-4 py-3 text-sm font-medium">';
|
||||
html += '<a href="/configurator?uuid=' + c.uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(c.name) + '</a>';
|
||||
if (subtitle) {
|
||||
html += '<div class="text-xs text-gray-500">' + subtitle + '</div>';
|
||||
}
|
||||
html += '</td>';
|
||||
}
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(author) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + pricePerUnit + '</td>';
|
||||
@@ -297,6 +319,30 @@ function renderConfigs(configs) {
|
||||
document.getElementById('configs-list').innerHTML = html;
|
||||
}
|
||||
|
||||
function projectDisplayKey(project) {
|
||||
const code = (project.code || '').trim();
|
||||
const variant = (project.variant || '').trim();
|
||||
if (!code) return '';
|
||||
return variant ? (code + ' (' + variant + ')') : code;
|
||||
}
|
||||
|
||||
function findProjectByInput(input) {
|
||||
const trimmed = (input || '').trim().toLowerCase();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const directMatch = projectsCache.find(p => projectDisplayKey(p).toLowerCase() === trimmed);
|
||||
if (directMatch) return directMatch;
|
||||
|
||||
const codeMatches = projectsCache.filter(p => (p.code || '').toLowerCase() === trimmed);
|
||||
if (codeMatches.length === 1) {
|
||||
return codeMatches[0];
|
||||
}
|
||||
if (codeMatches.length > 1) {
|
||||
alert('У проекта несколько вариантов. Укажите вариант в формате "CODE (variant)".');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
@@ -434,17 +480,21 @@ async function createConfig() {
|
||||
return;
|
||||
}
|
||||
|
||||
const projectName = document.getElementById('create-project-input').value.trim();
|
||||
const projectCode = document.getElementById('create-project-input').value.trim();
|
||||
let projectUUID = '';
|
||||
|
||||
if (projectName) {
|
||||
const existingProject = projectsCache.find(p => p.is_active && p.name.toLowerCase() === projectName.toLowerCase());
|
||||
if (existingProject) {
|
||||
projectUUID = existingProject.uuid;
|
||||
if (projectCode) {
|
||||
const matchedProject = findProjectByInput(projectCode);
|
||||
if (matchedProject) {
|
||||
if (!matchedProject.is_active) {
|
||||
alert('Проект с таким кодом находится в архиве. Восстановите его или выберите другой.');
|
||||
return;
|
||||
}
|
||||
projectUUID = matchedProject.uuid;
|
||||
} else {
|
||||
pendingCreateConfigName = name;
|
||||
pendingCreateProjectName = projectName;
|
||||
openCreateProjectOnCreateModal(projectName);
|
||||
pendingCreateProjectCode = projectCode;
|
||||
openCreateProjectOnCreateModal(projectCode);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -492,12 +542,14 @@ function openMoveProjectModal(uuid, configName, currentProjectUUID) {
|
||||
projectsCache.forEach(project => {
|
||||
if (!project.is_active) return;
|
||||
const option = document.createElement('option');
|
||||
option.value = project.name;
|
||||
option.value = projectDisplayKey(project);
|
||||
option.label = project.name || '';
|
||||
options.appendChild(option);
|
||||
});
|
||||
|
||||
if (currentProjectUUID && projectNameByUUID[currentProjectUUID]) {
|
||||
input.value = projectNameByUUID[currentProjectUUID];
|
||||
if (currentProjectUUID && projectCodeByUUID[currentProjectUUID]) {
|
||||
const variant = projectVariantByUUID[currentProjectUUID] || '';
|
||||
input.value = variant ? (projectCodeByUUID[currentProjectUUID] + ' (' + variant + ')') : projectCodeByUUID[currentProjectUUID];
|
||||
} else {
|
||||
input.value = '';
|
||||
}
|
||||
@@ -513,19 +565,23 @@ function closeMoveProjectModal() {
|
||||
|
||||
async function confirmMoveProject() {
|
||||
const uuid = document.getElementById('move-project-uuid').value;
|
||||
const projectName = document.getElementById('move-project-input').value.trim();
|
||||
const projectCode = document.getElementById('move-project-input').value.trim();
|
||||
|
||||
if (!uuid) return;
|
||||
let projectUUID = '';
|
||||
|
||||
if (projectName) {
|
||||
const existingProject = projectsCache.find(p => p.is_active && p.name.toLowerCase() === projectName.toLowerCase());
|
||||
if (existingProject) {
|
||||
projectUUID = existingProject.uuid;
|
||||
if (projectCode) {
|
||||
const matchedProject = findProjectByInput(projectCode);
|
||||
if (matchedProject) {
|
||||
if (!matchedProject.is_active) {
|
||||
alert('Проект с таким кодом находится в архиве. Восстановите его или выберите другой.');
|
||||
return;
|
||||
}
|
||||
projectUUID = matchedProject.uuid;
|
||||
} else {
|
||||
pendingMoveConfigUUID = uuid;
|
||||
pendingMoveProjectName = projectName;
|
||||
openCreateProjectOnMoveModal(projectName);
|
||||
pendingMoveProjectCode = projectCode;
|
||||
openCreateProjectOnMoveModal(projectCode);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -542,7 +598,9 @@ function clearCreateProjectInput() {
|
||||
}
|
||||
|
||||
function openCreateProjectOnMoveModal(projectName) {
|
||||
document.getElementById('create-project-on-move-name').textContent = projectName;
|
||||
document.getElementById('create-project-on-move-code').textContent = projectName;
|
||||
document.getElementById('create-project-on-move-name').value = projectName;
|
||||
document.getElementById('create-project-on-move-variant').value = '';
|
||||
document.getElementById('create-project-on-move-description').textContent = 'Создать и привязать квоту?';
|
||||
document.getElementById('create-project-on-move-confirm-btn').textContent = 'Создать и привязать';
|
||||
document.getElementById('create-project-on-move-modal').classList.remove('hidden');
|
||||
@@ -550,7 +608,9 @@ function openCreateProjectOnMoveModal(projectName) {
|
||||
}
|
||||
|
||||
function openCreateProjectOnCreateModal(projectName) {
|
||||
document.getElementById('create-project-on-move-name').textContent = projectName;
|
||||
document.getElementById('create-project-on-move-code').textContent = projectName;
|
||||
document.getElementById('create-project-on-move-name').value = projectName;
|
||||
document.getElementById('create-project-on-move-variant').value = '';
|
||||
document.getElementById('create-project-on-move-description').textContent = 'Создать и использовать для новой конфигурации?';
|
||||
document.getElementById('create-project-on-move-confirm-btn').textContent = 'Создать и использовать';
|
||||
document.getElementById('create-project-on-move-modal').classList.remove('hidden');
|
||||
@@ -561,22 +621,32 @@ function closeCreateProjectOnMoveModal() {
|
||||
document.getElementById('create-project-on-move-modal').classList.add('hidden');
|
||||
document.getElementById('create-project-on-move-modal').classList.remove('flex');
|
||||
pendingMoveConfigUUID = '';
|
||||
pendingMoveProjectName = '';
|
||||
pendingMoveProjectCode = '';
|
||||
pendingCreateConfigName = '';
|
||||
pendingCreateProjectName = '';
|
||||
pendingCreateProjectCode = '';
|
||||
document.getElementById('create-project-on-move-name').value = '';
|
||||
document.getElementById('create-project-on-move-variant').value = '';
|
||||
}
|
||||
|
||||
async function confirmCreateProjectOnMove() {
|
||||
if (pendingCreateConfigName && pendingCreateProjectName) {
|
||||
const projectNameInput = document.getElementById('create-project-on-move-name');
|
||||
const projectVariantInput = document.getElementById('create-project-on-move-variant');
|
||||
const projectName = (projectNameInput.value || '').trim();
|
||||
const projectVariant = (projectVariantInput.value || '').trim();
|
||||
if (pendingCreateConfigName && pendingCreateProjectCode) {
|
||||
const configName = pendingCreateConfigName;
|
||||
const projectName = pendingCreateProjectName;
|
||||
const projectCode = pendingCreateProjectCode;
|
||||
try {
|
||||
const createResp = await fetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ name: projectName })
|
||||
body: JSON.stringify({ name: projectName, code: projectCode, variant: projectVariant })
|
||||
});
|
||||
if (!createResp.ok) {
|
||||
if (createResp.status === 409) {
|
||||
alert('Проект с таким кодом и вариантом уже существует');
|
||||
return;
|
||||
}
|
||||
const err = await createResp.json();
|
||||
alert('Не удалось создать проект: ' + (err.error || 'ошибка'));
|
||||
return;
|
||||
@@ -584,14 +654,14 @@ async function confirmCreateProjectOnMove() {
|
||||
|
||||
const newProject = await createResp.json();
|
||||
pendingCreateConfigName = '';
|
||||
pendingCreateProjectName = '';
|
||||
pendingCreateProjectCode = '';
|
||||
await loadProjectsForConfigUI();
|
||||
const created = await createConfigWithProject(configName, newProject.uuid);
|
||||
if (created) {
|
||||
closeCreateProjectOnMoveModal();
|
||||
} else {
|
||||
closeCreateProjectOnMoveModal();
|
||||
document.getElementById('create-project-input').value = projectName;
|
||||
document.getElementById('create-project-input').value = projectCode;
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Ошибка создания проекта');
|
||||
@@ -600,8 +670,8 @@ async function confirmCreateProjectOnMove() {
|
||||
}
|
||||
|
||||
const configUUID = pendingMoveConfigUUID;
|
||||
const projectName = pendingMoveProjectName;
|
||||
if (!configUUID || !projectName) {
|
||||
const projectCode = pendingMoveProjectCode;
|
||||
if (!configUUID || !projectCode) {
|
||||
closeCreateProjectOnMoveModal();
|
||||
return;
|
||||
}
|
||||
@@ -610,9 +680,13 @@ async function confirmCreateProjectOnMove() {
|
||||
const createResp = await fetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ name: projectName })
|
||||
body: JSON.stringify({ name: projectName, code: projectCode, variant: projectVariant })
|
||||
});
|
||||
if (!createResp.ok) {
|
||||
if (createResp.status === 409) {
|
||||
alert('Проект с таким кодом и вариантом уже существует');
|
||||
return;
|
||||
}
|
||||
const err = await createResp.json();
|
||||
alert('Не удалось создать проект: ' + (err.error || 'ошибка'));
|
||||
return;
|
||||
@@ -620,9 +694,9 @@ async function confirmCreateProjectOnMove() {
|
||||
|
||||
const newProject = await createResp.json();
|
||||
pendingMoveConfigUUID = '';
|
||||
pendingMoveProjectName = '';
|
||||
pendingMoveProjectCode = '';
|
||||
await loadProjectsForConfigUI();
|
||||
document.getElementById('move-project-input').value = projectName;
|
||||
document.getElementById('move-project-input').value = projectCode;
|
||||
const moved = await moveConfigToProject(configUUID, newProject.uuid);
|
||||
if (moved) {
|
||||
closeCreateProjectOnMoveModal();
|
||||
@@ -785,44 +859,19 @@ async function loadConfigs() {
|
||||
}
|
||||
}
|
||||
|
||||
async function importConfigsFromServer() {
|
||||
const button = document.getElementById('import-configs-btn');
|
||||
const originalText = button.textContent;
|
||||
button.disabled = true;
|
||||
button.textContent = 'Импорт...';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/configs/import', { method: 'POST' });
|
||||
const data = await resp.json();
|
||||
|
||||
if (!resp.ok) {
|
||||
alert('Ошибка импорта: ' + (data.error || 'неизвестная ошибка'));
|
||||
return;
|
||||
}
|
||||
|
||||
alert(
|
||||
'Импорт завершен:\n' +
|
||||
'- Новых: ' + (data.imported || 0) + '\n' +
|
||||
'- Обновлено: ' + (data.updated || 0) + '\n' +
|
||||
'- Пропущено (локальные изменения): ' + (data.skipped || 0)
|
||||
);
|
||||
|
||||
currentPage = 1;
|
||||
await loadConfigs();
|
||||
} catch (e) {
|
||||
alert('Ошибка импорта с сервера');
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
applyStatusModeUI();
|
||||
loadProjectsForConfigUI().then(loadConfigs);
|
||||
|
||||
// Load latest pricelist version for badge
|
||||
loadLatestPricelistVersion();
|
||||
|
||||
// Listen for sync completion events from navbar
|
||||
window.addEventListener('sync-completed', function(e) {
|
||||
// Reset pagination and reload configurations list
|
||||
currentPage = 1;
|
||||
loadConfigs();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('configs-search').addEventListener('input', function(e) {
|
||||
@@ -834,14 +883,25 @@ document.getElementById('configs-search').addEventListener('input', function(e)
|
||||
async function loadProjectsForConfigUI() {
|
||||
projectsCache = [];
|
||||
projectNameByUUID = {};
|
||||
projectCodeByUUID = {};
|
||||
projectVariantByUUID = {};
|
||||
try {
|
||||
const resp = await fetch('/api/projects?status=all');
|
||||
// Use /api/projects/all to get all projects without pagination
|
||||
const resp = await fetch('/api/projects/all');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
projectsCache = (data.projects || []);
|
||||
// data is now a simple array of {uuid, name} objects
|
||||
const allProjects = Array.isArray(data) ? data : (data.projects || []);
|
||||
|
||||
projectsCache.forEach(project => {
|
||||
projectNameByUUID[project.uuid] = project.name;
|
||||
// For compatibility with rest of code, populate projectsCache but mainly use projectNameByUUID
|
||||
projectsCache = allProjects;
|
||||
|
||||
allProjects.forEach(project => {
|
||||
const variant = (project.variant || '').trim();
|
||||
const baseName = project.name || '';
|
||||
projectNameByUUID[project.uuid] = variant ? (baseName + ' (' + variant + ')') : baseName;
|
||||
projectCodeByUUID[project.uuid] = project.code || '';
|
||||
projectVariantByUUID[project.uuid] = project.variant || '';
|
||||
});
|
||||
|
||||
const createOptions = document.getElementById('create-project-options');
|
||||
@@ -850,7 +910,8 @@ async function loadProjectsForConfigUI() {
|
||||
projectsCache.forEach(project => {
|
||||
if (!project.is_active) return;
|
||||
const option = document.createElement('option');
|
||||
option.value = project.name;
|
||||
option.value = projectDisplayKey(project);
|
||||
option.label = project.name || '';
|
||||
createOptions.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,14 +5,27 @@
|
||||
<!-- Header with config name and back button -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="/configs" class="text-gray-500 hover:text-gray-700">
|
||||
<a href="/projects" class="text-gray-500 hover:text-gray-700" title="Все проекты">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9.75L12 3l9 6.75v9A2.25 2.25 0 0118.75 21h-13.5A2.25 2.25 0 013 18.75v-9z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 21v-6h6v6"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold">
|
||||
<span id="config-name">Конфигуратор</span>
|
||||
</h1>
|
||||
<div class="text-2xl font-bold flex items-center gap-2" id="config-breadcrumbs">
|
||||
<a id="breadcrumb-project-code-link" href="/projects" class="text-blue-700 hover:underline">
|
||||
<span id="breadcrumb-project-code">—</span>
|
||||
</a>
|
||||
<span class="text-gray-400">-</span>
|
||||
<a id="breadcrumb-project-variant-link" href="/projects" class="text-blue-700 hover:underline">
|
||||
<span id="breadcrumb-project-variant">main</span>
|
||||
</a>
|
||||
<span class="text-gray-400">-</span>
|
||||
<a id="breadcrumb-config-name-link" href="#" class="text-blue-700 hover:underline">
|
||||
<span id="breadcrumb-config-name">Конфигуратор</span>
|
||||
</a>
|
||||
<span class="text-gray-400">-</span>
|
||||
<span id="breadcrumb-config-version">v1</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="save-buttons" class="hidden flex items-center space-x-2">
|
||||
<button id="refresh-prices-btn" onclick="refreshPrices()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||
@@ -98,6 +111,7 @@
|
||||
</svg>
|
||||
</button>
|
||||
<div id="cart-summary-content" class="p-4">
|
||||
<div id="article-display" class="text-sm text-gray-700 mb-3 font-mono"></div>
|
||||
<div id="cart-items" class="space-y-2 mb-4"></div>
|
||||
<div class="border-t pt-3 flex justify-between items-center">
|
||||
<div class="text-lg font-bold">
|
||||
@@ -326,17 +340,104 @@ let ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
|
||||
// State
|
||||
let configUUID = '{{.ConfigUUID}}';
|
||||
let configName = '';
|
||||
let currentVersionNo = 1;
|
||||
let projectUUID = '';
|
||||
let projectName = '';
|
||||
let projectCode = '';
|
||||
let projectVariant = '';
|
||||
let projectIndexLoaded = false;
|
||||
let projectByUUID = {};
|
||||
let projectMainByCode = {};
|
||||
|
||||
async function loadProjectIndex() {
|
||||
if (projectIndexLoaded) return;
|
||||
try {
|
||||
const resp = await fetch('/api/projects/all');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const allProjects = Array.isArray(data) ? data : (data.projects || []);
|
||||
projectByUUID = {};
|
||||
projectMainByCode = {};
|
||||
allProjects.forEach(p => {
|
||||
projectByUUID[p.uuid] = p;
|
||||
const code = (p.code || '').trim();
|
||||
const variant = (p.variant || '').trim();
|
||||
if (code && (variant === '' || variant === 'main')) {
|
||||
if (!projectMainByCode[code]) {
|
||||
projectMainByCode[code] = p.uuid;
|
||||
}
|
||||
}
|
||||
});
|
||||
projectIndexLoaded = true;
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function updateConfigBreadcrumbs() {
|
||||
const codeEl = document.getElementById('breadcrumb-project-code');
|
||||
const variantEl = document.getElementById('breadcrumb-project-variant');
|
||||
const configEl = document.getElementById('breadcrumb-config-name');
|
||||
const versionEl = document.getElementById('breadcrumb-config-version');
|
||||
const projectCodeLinkEl = document.getElementById('breadcrumb-project-code-link');
|
||||
const projectVariantLinkEl = document.getElementById('breadcrumb-project-variant-link');
|
||||
|
||||
let code = 'Без проекта';
|
||||
let variant = 'main';
|
||||
if (projectUUID && projectByUUID[projectUUID]) {
|
||||
code = (projectByUUID[projectUUID].code || '').trim() || 'Без проекта';
|
||||
const rawVariant = (projectByUUID[projectUUID].variant || '').trim();
|
||||
variant = rawVariant === '' ? 'main' : rawVariant;
|
||||
if (projectCodeLinkEl) {
|
||||
const mainUUID = projectMainByCode[code];
|
||||
projectCodeLinkEl.href = mainUUID ? ('/projects/' + mainUUID) : ('/projects/' + projectUUID);
|
||||
}
|
||||
if (projectVariantLinkEl) {
|
||||
projectVariantLinkEl.href = '/projects/' + projectUUID;
|
||||
}
|
||||
} else {
|
||||
if (projectCodeLinkEl) projectCodeLinkEl.href = '/projects';
|
||||
if (projectVariantLinkEl) projectVariantLinkEl.href = '/projects';
|
||||
}
|
||||
codeEl.textContent = code;
|
||||
variantEl.textContent = variant;
|
||||
const fullConfigName = configName || 'Конфигурация';
|
||||
configEl.textContent = truncateBreadcrumbSpecName(fullConfigName);
|
||||
configEl.title = fullConfigName;
|
||||
versionEl.textContent = 'v' + (currentVersionNo || 1);
|
||||
const configNameLinkEl = document.getElementById('breadcrumb-config-name-link');
|
||||
if (configNameLinkEl && configUUID) {
|
||||
configNameLinkEl.href = '/configs/' + configUUID + '/revisions';
|
||||
}
|
||||
}
|
||||
|
||||
function truncateBreadcrumbSpecName(name) {
|
||||
const maxLength = 16;
|
||||
if (!name || name.length <= maxLength) return name;
|
||||
return name.slice(0, maxLength - 1) + '…';
|
||||
}
|
||||
let currentTab = 'base';
|
||||
let allComponents = [];
|
||||
let cart = [];
|
||||
let categoryOrderMap = {}; // Category code -> display_order mapping
|
||||
let autoSaveTimeout = null; // Timeout for debounced autosave
|
||||
let hasUnsavedChanges = false;
|
||||
let exitSaveStarted = false;
|
||||
let serverCount = 1; // Server count for the configuration
|
||||
let serverModelForQuote = '';
|
||||
let supportCode = '';
|
||||
let currentArticle = '';
|
||||
let articlePreviewTimeout = null;
|
||||
let selectedPricelistIds = {
|
||||
estimate: null,
|
||||
warehouse: null,
|
||||
competitor: null
|
||||
};
|
||||
let resolvedAutoPricelistIds = {
|
||||
estimate: null,
|
||||
warehouse: null,
|
||||
competitor: null
|
||||
};
|
||||
let disablePriceRefresh = false;
|
||||
let onlyInStock = false;
|
||||
let activePricelistsBySource = {
|
||||
@@ -351,6 +452,8 @@ let priceLevelsRefreshTimer = null;
|
||||
let warehouseStockLotsByPricelist = new Map();
|
||||
let warehouseStockLoadSeq = 0;
|
||||
let warehouseStockLoadsByPricelist = new Map();
|
||||
let componentPricesCache = {}; // { lot_name: price } - caches prices loaded via API
|
||||
let componentPricesCacheLoading = new Map(); // { category: Promise } - tracks ongoing price loads
|
||||
|
||||
// Autocomplete state
|
||||
let autocompleteInput = null;
|
||||
@@ -400,6 +503,22 @@ function formatDelta(abs, pct) {
|
||||
return sign + formatMoney(absValue) + ' (' + pctSign + Math.round(Math.abs(pct)) + '%)';
|
||||
}
|
||||
|
||||
function getEffectivePricelistID(source) {
|
||||
const explicit = selectedPricelistIds[source];
|
||||
if (Number.isFinite(explicit) && explicit > 0) {
|
||||
return Number(explicit);
|
||||
}
|
||||
const resolvedAuto = resolvedAutoPricelistIds[source];
|
||||
if (Number.isFinite(resolvedAuto) && resolvedAuto > 0) {
|
||||
return Number(resolvedAuto);
|
||||
}
|
||||
const fallback = activePricelistsBySource[source]?.[0]?.id;
|
||||
if (Number.isFinite(fallback) && fallback > 0) {
|
||||
return Number(fallback);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function refreshPriceLevels(options = {}) {
|
||||
const force = options.force === true;
|
||||
const noCache = options.noCache === true;
|
||||
@@ -445,12 +564,10 @@ async function refreshPriceLevels(options = {}) {
|
||||
if (data.resolved_pricelist_ids) {
|
||||
['estimate', 'warehouse', 'competitor'].forEach(source => {
|
||||
if (!selectedPricelistIds[source] && data.resolved_pricelist_ids[source]) {
|
||||
selectedPricelistIds[source] = data.resolved_pricelist_ids[source];
|
||||
resolvedAutoPricelistIds[source] = Number(data.resolved_pricelist_ids[source]);
|
||||
}
|
||||
});
|
||||
syncPriceSettingsControls();
|
||||
renderPricelistSettingsSummary();
|
||||
persistLocalPriceSettings();
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('Failed to refresh price levels', e);
|
||||
@@ -483,11 +600,7 @@ function schedulePriceLevelsRefresh(options = {}) {
|
||||
}
|
||||
|
||||
function currentWarehousePricelistID() {
|
||||
const id = selectedPricelistIds.warehouse;
|
||||
if (Number.isFinite(id) && id > 0) return Number(id);
|
||||
const fallback = activePricelistsBySource.warehouse?.[0]?.id;
|
||||
if (Number.isFinite(fallback) && fallback > 0) return Number(fallback);
|
||||
return null;
|
||||
return getEffectivePricelistID('warehouse');
|
||||
}
|
||||
|
||||
async function loadWarehouseInStockLots() {
|
||||
@@ -607,7 +720,10 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
|
||||
const config = await resp.json();
|
||||
configName = config.name;
|
||||
document.getElementById('config-name').textContent = config.name;
|
||||
currentVersionNo = config.current_version_no || 1;
|
||||
projectUUID = config.project_uuid || '';
|
||||
await loadProjectIndex();
|
||||
updateConfigBreadcrumbs();
|
||||
document.getElementById('save-buttons').classList.remove('hidden');
|
||||
|
||||
// Set server count from config
|
||||
@@ -629,6 +745,9 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
category: item.category || getCategoryFromLotName(item.lot_name)
|
||||
}));
|
||||
}
|
||||
serverModelForQuote = config.server_model || '';
|
||||
supportCode = config.support_code || '';
|
||||
currentArticle = config.article || '';
|
||||
|
||||
// Restore custom price if saved
|
||||
if (config.custom_price) {
|
||||
@@ -719,9 +838,7 @@ async function loadActivePricelists(force = false) {
|
||||
if (existing && activePricelistsBySource[source].some(pl => Number(pl.id) === Number(existing))) {
|
||||
return;
|
||||
}
|
||||
selectedPricelistIds[source] = activePricelistsBySource[source].length > 0
|
||||
? Number(activePricelistsBySource[source][0].id)
|
||||
: null;
|
||||
selectedPricelistIds[source] = null;
|
||||
} catch (e) {
|
||||
activePricelistsBySource[source] = [];
|
||||
selectedPricelistIds[source] = null;
|
||||
@@ -857,6 +974,15 @@ function applyPriceSettings() {
|
||||
selectedPricelistIds.estimate = Number.isFinite(estimateVal) && estimateVal > 0 ? estimateVal : null;
|
||||
selectedPricelistIds.warehouse = Number.isFinite(warehouseVal) && warehouseVal > 0 ? warehouseVal : null;
|
||||
selectedPricelistIds.competitor = Number.isFinite(competitorVal) && competitorVal > 0 ? competitorVal : null;
|
||||
if (selectedPricelistIds.estimate) {
|
||||
resolvedAutoPricelistIds.estimate = null;
|
||||
}
|
||||
if (selectedPricelistIds.warehouse) {
|
||||
resolvedAutoPricelistIds.warehouse = null;
|
||||
}
|
||||
if (selectedPricelistIds.competitor) {
|
||||
resolvedAutoPricelistIds.competitor = null;
|
||||
}
|
||||
disablePriceRefresh = disableVal;
|
||||
onlyInStock = inStockVal;
|
||||
|
||||
@@ -943,7 +1069,32 @@ function renderTab() {
|
||||
}
|
||||
|
||||
function renderSingleSelectTab(categories) {
|
||||
let html = `
|
||||
let html = '';
|
||||
if (currentTab === 'base') {
|
||||
html += `
|
||||
<div class="mb-1 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
|
||||
<label for="server-model-input" class="block text-sm font-medium text-gray-700">Модель сервера для КП:</label>
|
||||
<label for="support-code-select" class="block text-sm font-medium text-gray-700">Уровень техподдержки:</label>
|
||||
</div>
|
||||
<div class="mb-3 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
|
||||
<input type="text"
|
||||
id="server-model-input"
|
||||
value="${escapeHtml(serverModelForQuote)}"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||||
oninput="updateServerModelForQuote(this.value)">
|
||||
<select id="support-code-select"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||||
onchange="updateSupportCode(this.value)">
|
||||
<option value="">—</option>
|
||||
<option value="1yW" ${supportCode === '1yW' ? 'selected' : ''}>1yW</option>
|
||||
<option value="1yB" ${supportCode === '1yB' ? 'selected' : ''}>1yB</option>
|
||||
<option value="1yS" ${supportCode === '1yS' ? 'selected' : ''}>1yS</option>
|
||||
<option value="1yP" ${supportCode === '1yP' ? 'selected' : ''}>1yP</option>
|
||||
</select>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
html += `
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
@@ -1201,12 +1352,54 @@ function renderMultiSelectTabWithSections(sections) {
|
||||
document.getElementById('tab-content').innerHTML = html;
|
||||
}
|
||||
|
||||
// Load prices for components in a category/tab via API
|
||||
async function ensurePricesLoaded(components) {
|
||||
if (!components || components.length === 0) return;
|
||||
|
||||
// Filter out components that already have prices cached
|
||||
const toLoad = components.filter(c => !(c.lot_name in componentPricesCache));
|
||||
if (toLoad.length === 0) return;
|
||||
|
||||
try {
|
||||
// Use quote/price-levels API to get prices for these components
|
||||
const resp = await fetch('/api/quote/price-levels', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
items: toLoad.map(c => ({ lot_name: c.lot_name, quantity: 1 })),
|
||||
pricelist_ids: Object.fromEntries(
|
||||
Object.entries(selectedPricelistIds)
|
||||
.filter(([, id]) => typeof id === 'number' && id > 0)
|
||||
)
|
||||
})
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
if (data.items) {
|
||||
data.items.forEach(item => {
|
||||
// Cache the estimate price (or 0 if not found)
|
||||
componentPricesCache[item.lot_name] = item.estimate_price || 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load component prices', e);
|
||||
}
|
||||
}
|
||||
|
||||
function hasComponentPrice(lotName) {
|
||||
return lotName in componentPricesCache && componentPricesCache[lotName] > 0;
|
||||
}
|
||||
|
||||
// Autocomplete for single select (Base tab)
|
||||
function showAutocomplete(category, input) {
|
||||
async function showAutocomplete(category, input) {
|
||||
autocompleteInput = input;
|
||||
autocompleteCategory = category;
|
||||
autocompleteMode = 'single';
|
||||
autocompleteIndex = -1;
|
||||
const components = getComponentsForCategory(category);
|
||||
await ensurePricesLoaded(components);
|
||||
filterAutocomplete(category, input.value);
|
||||
}
|
||||
|
||||
@@ -1215,7 +1408,7 @@ function filterAutocomplete(category, search) {
|
||||
const searchLower = search.toLowerCase();
|
||||
|
||||
autocompleteFiltered = components.filter(c => {
|
||||
if (!c.current_price) return false;
|
||||
if (!hasComponentPrice(c.lot_name)) return false;
|
||||
if (!isComponentAllowedByStockFilter(c)) return false;
|
||||
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
|
||||
return text.includes(searchLower);
|
||||
@@ -1298,12 +1491,13 @@ function selectAutocompleteItem(index) {
|
||||
|
||||
const qtyInput = document.getElementById('qty-' + autocompleteCategory);
|
||||
const qty = parseInt(qtyInput?.value) || 1;
|
||||
const price = componentPricesCache[comp.lot_name] || 0;
|
||||
|
||||
cart.push({
|
||||
lot_name: comp.lot_name,
|
||||
quantity: qty,
|
||||
unit_price: comp.current_price,
|
||||
estimate_price: comp.current_price,
|
||||
unit_price: price,
|
||||
estimate_price: price,
|
||||
warehouse_price: null,
|
||||
competitor_price: null,
|
||||
delta_wh_estimate_abs: null,
|
||||
@@ -1333,11 +1527,13 @@ function hideAutocomplete() {
|
||||
}
|
||||
|
||||
// Autocomplete for multi select tabs
|
||||
function showAutocompleteMulti(input) {
|
||||
async function showAutocompleteMulti(input) {
|
||||
autocompleteInput = input;
|
||||
autocompleteCategory = null;
|
||||
autocompleteMode = 'multi';
|
||||
autocompleteIndex = -1;
|
||||
const components = getComponentsForTab(currentTab);
|
||||
await ensurePricesLoaded(components);
|
||||
filterAutocompleteMulti(input.value);
|
||||
}
|
||||
|
||||
@@ -1349,7 +1545,7 @@ function filterAutocompleteMulti(search) {
|
||||
const addedLots = new Set(cart.map(i => i.lot_name));
|
||||
|
||||
autocompleteFiltered = components.filter(c => {
|
||||
if (!c.current_price) return false;
|
||||
if (!hasComponentPrice(c.lot_name)) return false;
|
||||
if (addedLots.has(c.lot_name)) return false;
|
||||
if (!isComponentAllowedByStockFilter(c)) return false;
|
||||
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
|
||||
@@ -1390,12 +1586,13 @@ function selectAutocompleteItemMulti(index) {
|
||||
|
||||
const qtyInput = document.getElementById('new-qty');
|
||||
const qty = parseInt(qtyInput?.value) || 1;
|
||||
const price = componentPricesCache[comp.lot_name] || 0;
|
||||
|
||||
cart.push({
|
||||
lot_name: comp.lot_name,
|
||||
quantity: qty,
|
||||
unit_price: comp.current_price,
|
||||
estimate_price: comp.current_price,
|
||||
unit_price: price,
|
||||
estimate_price: price,
|
||||
warehouse_price: null,
|
||||
competitor_price: null,
|
||||
delta_wh_estimate_abs: null,
|
||||
@@ -1417,11 +1614,16 @@ function selectAutocompleteItemMulti(index) {
|
||||
}
|
||||
|
||||
// Autocomplete for sectioned tabs (like storage with RAID and Disks sections)
|
||||
function showAutocompleteSection(sectionId, input) {
|
||||
async function showAutocompleteSection(sectionId, input) {
|
||||
autocompleteInput = input;
|
||||
autocompleteCategory = sectionId; // Store section ID
|
||||
autocompleteMode = 'section';
|
||||
autocompleteIndex = -1;
|
||||
|
||||
// Load prices for tab components
|
||||
const components = getComponentsForTab(currentTab);
|
||||
await ensurePricesLoaded(components);
|
||||
|
||||
filterAutocompleteSection(sectionId, input.value, input);
|
||||
}
|
||||
|
||||
@@ -1448,7 +1650,7 @@ function filterAutocompleteSection(sectionId, search, inputElement) {
|
||||
const addedLots = new Set(cart.map(i => i.lot_name));
|
||||
|
||||
autocompleteFiltered = sectionComponents.filter(c => {
|
||||
if (!c.current_price) return false;
|
||||
if (!hasComponentPrice(c.lot_name)) return false;
|
||||
if (addedLots.has(c.lot_name)) return false;
|
||||
if (!isComponentAllowedByStockFilter(c)) return false;
|
||||
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
|
||||
@@ -1489,12 +1691,13 @@ function selectAutocompleteItemSection(index, sectionId) {
|
||||
|
||||
const qtyInput = document.getElementById('new-qty-' + sectionId);
|
||||
const qty = parseInt(qtyInput?.value) || 1;
|
||||
const price = componentPricesCache[comp.lot_name] || 0;
|
||||
|
||||
cart.push({
|
||||
lot_name: comp.lot_name,
|
||||
quantity: qty,
|
||||
unit_price: comp.current_price,
|
||||
estimate_price: comp.current_price,
|
||||
unit_price: price,
|
||||
estimate_price: price,
|
||||
warehouse_price: null,
|
||||
competitor_price: null,
|
||||
delta_wh_estimate_abs: null,
|
||||
@@ -1579,6 +1782,8 @@ function updateCartUI() {
|
||||
calculateCustomPrice();
|
||||
renderSalePriceTable();
|
||||
|
||||
scheduleArticlePreview();
|
||||
|
||||
if (cart.length === 0) {
|
||||
document.getElementById('cart-items').innerHTML =
|
||||
'<div class="text-gray-500 text-center py-2">Конфигурация пуста</div>';
|
||||
@@ -1654,13 +1859,192 @@ function escapeHtml(text) {
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function updateServerModelForQuote(value) {
|
||||
serverModelForQuote = value || '';
|
||||
scheduleArticlePreview();
|
||||
}
|
||||
|
||||
function updateSupportCode(value) {
|
||||
supportCode = value || '';
|
||||
scheduleArticlePreview();
|
||||
}
|
||||
|
||||
function scheduleArticlePreview() {
|
||||
if (articlePreviewTimeout) {
|
||||
clearTimeout(articlePreviewTimeout);
|
||||
}
|
||||
articlePreviewTimeout = setTimeout(() => {
|
||||
previewArticle();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
async function previewArticle() {
|
||||
const el = document.getElementById('article-display');
|
||||
if (!el) return;
|
||||
|
||||
const model = serverModelForQuote.trim();
|
||||
const estimatePricelistID = getEffectivePricelistID('estimate');
|
||||
if (!model || !estimatePricelistID || cart.length === 0) {
|
||||
currentArticle = '';
|
||||
el.textContent = 'Артикул: —';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/configs/preview-article', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
server_model: serverModelForQuote,
|
||||
support_code: supportCode,
|
||||
pricelist_id: estimatePricelistID,
|
||||
items: cart.map(item => ({
|
||||
lot_name: item.lot_name,
|
||||
quantity: item.quantity,
|
||||
unit_price: item.unit_price || 0
|
||||
}))
|
||||
})
|
||||
});
|
||||
if (!resp.ok) {
|
||||
currentArticle = '';
|
||||
el.textContent = 'Артикул: —';
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
currentArticle = data.article || '';
|
||||
el.textContent = currentArticle ? ('Артикул: ' + currentArticle) : 'Артикул: —';
|
||||
} catch(e) {
|
||||
currentArticle = '';
|
||||
el.textContent = 'Артикул: —';
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentArticle() {
|
||||
return currentArticle || '';
|
||||
}
|
||||
|
||||
function getAutosaveStorageKey() {
|
||||
return `qf_config_autosave_${configUUID || 'default'}`;
|
||||
}
|
||||
|
||||
function buildSavePayload() {
|
||||
const customPriceInput = document.getElementById('custom-price-input');
|
||||
const customPriceValue = parseFloat(customPriceInput.value);
|
||||
const customPrice = customPriceValue > 0 ? customPriceValue : null;
|
||||
|
||||
return {
|
||||
name: configName,
|
||||
items: cart,
|
||||
custom_price: customPrice,
|
||||
notes: '',
|
||||
server_count: serverCount,
|
||||
server_model: serverModelForQuote,
|
||||
support_code: supportCode,
|
||||
article: getCurrentArticle(),
|
||||
pricelist_id: selectedPricelistIds.estimate,
|
||||
only_in_stock: onlyInStock
|
||||
};
|
||||
}
|
||||
|
||||
function persistAutosaveDraft() {
|
||||
if (!configUUID) return;
|
||||
try {
|
||||
sessionStorage.setItem(getAutosaveStorageKey(), JSON.stringify({
|
||||
payload: buildSavePayload(),
|
||||
saved_at: Date.now()
|
||||
}));
|
||||
} catch (_) {
|
||||
// ignore storage failures
|
||||
}
|
||||
}
|
||||
|
||||
function clearAutosaveDraft() {
|
||||
try {
|
||||
sessionStorage.removeItem(getAutosaveStorageKey());
|
||||
} catch (_) {
|
||||
// ignore storage failures
|
||||
}
|
||||
}
|
||||
|
||||
function restoreAutosaveDraftIfAny() {
|
||||
if (!configUUID) return;
|
||||
let raw = null;
|
||||
try {
|
||||
raw = sessionStorage.getItem(getAutosaveStorageKey());
|
||||
} catch (_) {
|
||||
raw = null;
|
||||
}
|
||||
if (!raw) return;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
const payload = parsed && parsed.payload ? parsed.payload : null;
|
||||
if (!payload) return;
|
||||
|
||||
if (Array.isArray(payload.items)) {
|
||||
cart = payload.items.map(item => ({
|
||||
lot_name: item.lot_name,
|
||||
quantity: item.quantity,
|
||||
unit_price: item.unit_price,
|
||||
estimate_price: item.unit_price,
|
||||
warehouse_price: null,
|
||||
competitor_price: null,
|
||||
description: item.description || '',
|
||||
category: item.category || getCategoryFromLotName(item.lot_name)
|
||||
}));
|
||||
}
|
||||
if (typeof payload.server_count === 'number' && payload.server_count > 0) {
|
||||
serverCount = payload.server_count;
|
||||
const serverCountInput = document.getElementById('server-count');
|
||||
if (serverCountInput) serverCountInput.value = serverCount;
|
||||
const totalServerCount = document.getElementById('total-server-count');
|
||||
if (totalServerCount) totalServerCount.textContent = serverCount;
|
||||
}
|
||||
serverModelForQuote = payload.server_model || serverModelForQuote;
|
||||
supportCode = payload.support_code || supportCode;
|
||||
currentArticle = payload.article || currentArticle;
|
||||
selectedPricelistIds.estimate = payload.pricelist_id || selectedPricelistIds.estimate;
|
||||
onlyInStock = Boolean(payload.only_in_stock);
|
||||
|
||||
const customPriceInput = document.getElementById('custom-price-input');
|
||||
if (customPriceInput) {
|
||||
if (typeof payload.custom_price === 'number' && payload.custom_price > 0) {
|
||||
customPriceInput.value = payload.custom_price.toFixed(2);
|
||||
} else {
|
||||
customPriceInput.value = '';
|
||||
}
|
||||
}
|
||||
hasUnsavedChanges = true;
|
||||
} catch (_) {
|
||||
// ignore invalid draft
|
||||
}
|
||||
}
|
||||
|
||||
function saveConfigOnExit() {
|
||||
if (!configUUID || !hasUnsavedChanges || exitSaveStarted) return;
|
||||
exitSaveStarted = true;
|
||||
try {
|
||||
fetch('/api/configs/' + configUUID, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(buildSavePayload()),
|
||||
keepalive: true
|
||||
});
|
||||
} catch (_) {
|
||||
// best effort save on page exit
|
||||
}
|
||||
}
|
||||
|
||||
function triggerAutoSave() {
|
||||
// Debounce autosave - wait 1 second after last change
|
||||
// Autosave keeps local draft only; server revision is created on Save/Exit.
|
||||
hasUnsavedChanges = true;
|
||||
if (autoSaveTimeout) {
|
||||
clearTimeout(autoSaveTimeout);
|
||||
}
|
||||
autoSaveTimeout = setTimeout(() => {
|
||||
saveConfig(false); // false = don't show notification
|
||||
persistAutosaveDraft();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
@@ -1674,29 +2058,13 @@ async function saveConfig(showNotification = true) {
|
||||
|
||||
await refreshPriceLevels({ force: true, noCache: true });
|
||||
|
||||
// Get custom price if set
|
||||
const customPriceInput = document.getElementById('custom-price-input');
|
||||
const customPriceValue = parseFloat(customPriceInput.value);
|
||||
const customPrice = customPriceValue > 0 ? customPriceValue : null;
|
||||
|
||||
// Get server count
|
||||
const serverCountValue = serverCount;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/configs/' + configUUID, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: configName,
|
||||
items: cart,
|
||||
custom_price: customPrice,
|
||||
notes: '',
|
||||
server_count: serverCountValue,
|
||||
pricelist_id: selectedPricelistIds.estimate,
|
||||
only_in_stock: onlyInStock
|
||||
})
|
||||
body: JSON.stringify(buildSavePayload())
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
@@ -1706,6 +2074,16 @@ async function saveConfig(showNotification = true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const saved = await resp.json();
|
||||
if (saved && saved.current_version_no) {
|
||||
currentVersionNo = saved.current_version_no;
|
||||
const versionEl = document.getElementById('breadcrumb-config-version');
|
||||
if (versionEl) versionEl.textContent = 'v' + currentVersionNo;
|
||||
}
|
||||
hasUnsavedChanges = false;
|
||||
clearAutosaveDraft();
|
||||
exitSaveStarted = false;
|
||||
|
||||
if (showNotification) {
|
||||
showToast('Сохранено', 'success');
|
||||
}
|
||||
@@ -1716,6 +2094,14 @@ async function saveConfig(showNotification = true) {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to extract filename from Content-Disposition header
|
||||
function getFilenameFromResponse(resp) {
|
||||
const contentDisposition = resp.headers.get('content-disposition');
|
||||
if (!contentDisposition) return null;
|
||||
const matches = contentDisposition.match(/filename="?([^"]+)"?/);
|
||||
return matches && matches[1] ? matches[1] : null;
|
||||
}
|
||||
|
||||
async function exportCSV() {
|
||||
if (cart.length === 0) return;
|
||||
|
||||
@@ -1730,17 +2116,19 @@ async function exportCSV() {
|
||||
...item,
|
||||
unit_price: getDisplayPrice(item),
|
||||
}));
|
||||
const article = getCurrentArticle();
|
||||
const resp = await fetch('/api/export/csv', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({items: exportItems, name: configName})
|
||||
body: JSON.stringify({items: exportItems, name: configName, project_uuid: projectUUID, article: article, server_count: serverCount, pricelist_id: selectedPricelistIds.estimate || null})
|
||||
});
|
||||
|
||||
const blob = await resp.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = (configName || 'config') + '.csv';
|
||||
const articleForName = article || 'BOM';
|
||||
a.download = getFilenameFromResponse(resp) || ((configName || 'config') + ' ' + articleForName + '.csv');
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch(e) {
|
||||
@@ -1986,14 +2374,14 @@ async function exportCSVWithCustomPrice() {
|
||||
const resp = await fetch('/api/export/csv', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({items: adjustedCart, name: configName})
|
||||
body: JSON.stringify({items: adjustedCart, name: configName, project_uuid: projectUUID})
|
||||
});
|
||||
|
||||
const blob = await resp.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = (configName || 'config') + '.csv';
|
||||
a.download = getFilenameFromResponse(resp) || (configName || 'config') + '.csv';
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch(e) {
|
||||
@@ -2043,13 +2431,19 @@ async function refreshPrices() {
|
||||
updatePriceUpdateDate(config.price_updated_at);
|
||||
}
|
||||
if (config.pricelist_id) {
|
||||
selectedPricelistIds.estimate = config.pricelist_id;
|
||||
if (selectedPricelistIds.estimate) {
|
||||
selectedPricelistIds.estimate = config.pricelist_id;
|
||||
} else {
|
||||
resolvedAutoPricelistIds.estimate = Number(config.pricelist_id);
|
||||
}
|
||||
if (!activePricelistsBySource.estimate.some(opt => Number(opt.id) === Number(config.pricelist_id))) {
|
||||
await loadActivePricelists();
|
||||
}
|
||||
syncPriceSettingsControls();
|
||||
renderPricelistSettingsSummary();
|
||||
persistLocalPriceSettings();
|
||||
if (selectedPricelistIds.estimate) {
|
||||
persistLocalPriceSettings();
|
||||
}
|
||||
}
|
||||
|
||||
// Re-render UI
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
{{define "content"}}
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="/pricelists" class="text-gray-500 hover:text-gray-700">
|
||||
<a href="/projects" class="text-gray-500 hover:text-gray-700" title="Все проекты">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9.75L12 3l9 6.75v9A2.25 2.25 0 0118.75 21h-13.5A2.25 2.25 0 013 18.75v-9z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 21v-6h6v6"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<h1 id="page-title" class="text-2xl font-bold text-gray-900">Загрузка...</h1>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Версия</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Тип</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase">Позиций</th>
|
||||
@@ -22,7 +23,7 @@
|
||||
</thead>
|
||||
<tbody id="pricelists-body" class="bg-white divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td colspan="7" class="px-6 py-4 text-center text-gray-500">Загрузка...</td>
|
||||
<td colspan="8" class="px-6 py-4 text-center text-gray-500">Загрузка...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -87,7 +88,7 @@
|
||||
} catch (e) {
|
||||
document.getElementById('pricelists-body').innerHTML = `
|
||||
<tr>
|
||||
<td colspan="7" class="px-6 py-4 text-center text-red-500">
|
||||
<td colspan="8" class="px-6 py-4 text-center text-red-500">
|
||||
Ошибка загрузки: ${e.message}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -99,7 +100,7 @@
|
||||
if (pricelists.length === 0) {
|
||||
document.getElementById('pricelists-body').innerHTML = `
|
||||
<tr>
|
||||
<td colspan="7" class="px-6 py-4 text-center text-gray-500">
|
||||
<td colspan="8" class="px-6 py-4 text-center text-gray-500">
|
||||
Прайслисты не найдены. ${canWrite ? 'Создайте первый прайслист.' : ''}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -109,6 +110,12 @@
|
||||
|
||||
const html = pricelists.map(pl => {
|
||||
const date = new Date(pl.created_at).toLocaleDateString('ru-RU');
|
||||
const sourceToType = {
|
||||
estimate: 'estimate',
|
||||
warehouse: 'stock',
|
||||
competitor: 'b2b'
|
||||
};
|
||||
const pricelistType = sourceToType[pl.source] || pl.source || '-';
|
||||
const statusClass = pl.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800';
|
||||
const statusText = pl.is_active ? 'Активен' : 'Неактивен';
|
||||
|
||||
@@ -122,6 +129,7 @@
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="font-mono text-sm">${pl.version}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${pricelistType}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${date}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${pl.created_by || '-'}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center text-sm">${pl.item_count}</td>
|
||||
@@ -227,6 +235,12 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
checkPricelistWritePermission();
|
||||
loadPricelists(1);
|
||||
|
||||
// Listen for sync completion events from navbar
|
||||
window.addEventListener('sync-completed', function(e) {
|
||||
// Reload pricelists on sync completion
|
||||
loadPricelists(1);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
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