Compare commits
20 Commits
v1.0.4
...
99fd80bca7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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/**
|
||||
|
||||
12
CLAUDE.md
12
CLAUDE.md
@@ -21,6 +21,12 @@
|
||||
- MariaDB используется как сервер синхронизации
|
||||
- Background worker: периодический sync push+pull
|
||||
|
||||
## Guardrails
|
||||
- Не возвращать в проект удалённые legacy-разделы: cron jobs, importer utility, admin pricing, alerts, stock import.
|
||||
- Runtime-конфиг читается из user state (`config.yaml`) или через `-config` / `QFS_CONFIG_PATH`; не хранить рабочий `config.yaml` в репозитории.
|
||||
- `config.example.yaml` остаётся единственным шаблоном конфигурации в репо.
|
||||
- Любые изменения в sync должны сохранять local-first поведение: локальные CRUD не блокируются из-за недоступности MariaDB.
|
||||
|
||||
## Key SQLite Data
|
||||
- `connection_settings`
|
||||
- `local_components`
|
||||
@@ -50,6 +56,12 @@
|
||||
- `/pricelists/:id`
|
||||
- `/setup`
|
||||
|
||||
## Release Notes & Change Log
|
||||
Release notes are maintained in `releases/memory/` directory organized by version tags (e.g., `v1.2.1.md`).
|
||||
Before working on the codebase, review the most recent release notes to understand recent changes.
|
||||
- Check `releases/memory/` for detailed changelog between tags
|
||||
- Each release file documents commits, breaking changes, and migration notes
|
||||
|
||||
## Commands
|
||||
```bash
|
||||
# Development
|
||||
|
||||
@@ -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)"
|
||||
|
||||
233
README.md
233
README.md
@@ -56,13 +56,13 @@ git clone https://github.com/your-company/quoteforge.git
|
||||
cd quoteforge
|
||||
```
|
||||
|
||||
### 2. Настройка конфигурации
|
||||
### 2. Настройка runtime-конфига (опционально)
|
||||
|
||||
```bash
|
||||
cp config.example.yaml config.yaml
|
||||
```
|
||||
`config.yaml` создаётся автоматически при первом старте в той же user-state папке, где находится `qfs.db`.
|
||||
Если найден старый формат, приложение автоматически мигрирует файл в актуальный runtime-формат
|
||||
(оставляя только используемые секции `server` и `logging`).
|
||||
|
||||
Отредактируйте `config.yaml`:
|
||||
При необходимости можно создать/отредактировать файл вручную:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
@@ -70,16 +70,10 @@ server:
|
||||
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"
|
||||
logging:
|
||||
level: "info"
|
||||
format: "json"
|
||||
output: "stdout"
|
||||
```
|
||||
|
||||
### 3. Миграции базы данных
|
||||
@@ -96,73 +90,100 @@ go run ./cmd/qfs -migrate
|
||||
Сначала всегда смотрите preview:
|
||||
|
||||
```bash
|
||||
go run ./cmd/migrate_ops_projects -config config.yaml
|
||||
go run ./cmd/migrate_ops_projects
|
||||
```
|
||||
|
||||
Применение изменений:
|
||||
|
||||
```bash
|
||||
go run ./cmd/migrate_ops_projects -config config.yaml -apply
|
||||
go run ./cmd/migrate_ops_projects -apply
|
||||
```
|
||||
|
||||
Без интерактивного подтверждения:
|
||||
|
||||
```bash
|
||||
go run ./cmd/migrate_ops_projects -config config.yaml -apply -yes
|
||||
go run ./cmd/migrate_ops_projects -apply -yes
|
||||
```
|
||||
|
||||
### Минимальные права БД для пользователя квотаций
|
||||
### Права БД для пользователя приложения
|
||||
|
||||
Если нужен пользователь, который может работать с конфигурациями, но не может создавать/удалять прайслисты:
|
||||
#### Полный набор прав для обычного пользователя
|
||||
|
||||
Чтобы выдать существующему пользователю все необходимые права (без переоздания):
|
||||
|
||||
```sql
|
||||
-- 1) Создать пользователя (если его ещё нет)
|
||||
CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY 'DB_PASSWORD_PLACEHOLDER';
|
||||
-- Справочные таблицы (только чтение)
|
||||
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>'@'%';
|
||||
|
||||
-- 2) Если пользователь уже существовал, принудительно обновить пароль
|
||||
ALTER USER 'quote_user'@'%' IDENTIFIED BY 'DB_PASSWORD_PLACEHOLDER';
|
||||
-- Таблицы конфигураций и проектов (чтение и запись)
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO '<DB_USER>'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO '<DB_USER>'@'%';
|
||||
|
||||
-- 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'@'%';
|
||||
-- Таблицы синхронизации (только чтение для миграций, чтение+запись для статуса)
|
||||
GRANT SELECT ON RFQ_LOG.qt_client_local_migrations TO '<DB_USER>'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO '<DB_USER>'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO '<DB_USER>'@'%';
|
||||
|
||||
-- Применить изменения
|
||||
FLUSH PRIVILEGES;
|
||||
|
||||
SHOW GRANTS FOR 'quote_user'@'%';
|
||||
SHOW CREATE USER 'quote_user'@'%';
|
||||
-- Проверка выданных прав
|
||||
SHOW GRANTS FOR '<DB_USER>'@'%';
|
||||
```
|
||||
|
||||
Полный набор прав для пользователя квотаций:
|
||||
#### Таблицы и их назначение
|
||||
|
||||
| Таблица | Назначение | Права | Примечание |
|
||||
|---------|-----------|-------|-----------|
|
||||
| `lot` | Справочник компонентов | SELECT | Существующая таблица |
|
||||
| `qt_lot_metadata` | Расширенные данные компонентов | SELECT | Метаданные компонентов |
|
||||
| `qt_categories` | Категории компонентов | SELECT | Справочник |
|
||||
| `qt_pricelists` | Прайслисты | SELECT | Управляется сервером |
|
||||
| `qt_pricelist_items` | Позиции прайслистов | SELECT | Управляется сервером |
|
||||
| `qt_configurations` | Сохранённые конфигурации | SELECT, INSERT, UPDATE | Основная таблица работы |
|
||||
| `qt_projects` | Проекты | SELECT, INSERT, UPDATE | Для группировки конфигураций |
|
||||
| `qt_client_local_migrations` | Справочник миграций БД | SELECT | Только чтение (управляется админом) |
|
||||
| `qt_client_schema_state` | Состояние локальной схемы | SELECT, INSERT, UPDATE | Отслеживание примененных миграций |
|
||||
| `qt_pricelist_sync_status` | Статус синхронизации | SELECT, INSERT, UPDATE | Отслеживание активности синхронизации |
|
||||
|
||||
#### При создании нового пользователя
|
||||
|
||||
Если нужно создать нового пользователя с нуля:
|
||||
|
||||
```sql
|
||||
GRANT USAGE ON *.* TO 'quote_user'@'%' IDENTIFIED BY 'DB_PASSWORD_PLACEHOLDER';
|
||||
-- 1) Создать пользователя
|
||||
CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY '<DB_PASSWORD>';
|
||||
|
||||
-- 2) Выдать все необходимые права
|
||||
GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_client_local_migrations TO 'quote_user'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO 'quote_user'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO 'quote_user'@'%';
|
||||
|
||||
-- 3) Применить изменения
|
||||
FLUSH PRIVILEGES;
|
||||
|
||||
-- 4) Проверить права
|
||||
SHOW GRANTS FOR 'quote_user'@'%';
|
||||
```
|
||||
|
||||
Важно:
|
||||
- не выдавайте `INSERT/UPDATE/DELETE` на `qt_pricelists` и `qt_pricelist_items`, если пользователь не должен управлять прайслистами;
|
||||
- если видите ошибку `Access denied for user ...@'<ip>'`, проверьте, что не осталось других записей `quote_user@host` кроме `quote_user@'%'`;
|
||||
- после смены DB-настроек через `/setup` приложение перезапускается автоматически и подхватывает нового пользователя.
|
||||
#### Важные замечания
|
||||
|
||||
- **Таблицы синхронизации** должны быть созданы администратором БД один раз. Приложение не требует прав CREATE TABLE.
|
||||
- **Прайслисты** (`qt_pricelists`, `qt_pricelist_items`) — справочные таблицы, управляются сервером, пользователь имеет только SELECT.
|
||||
- **Конфигурации и проекты** — таблицы, в которые пишет само приложение (INSERT, UPDATE при сохранении изменений).
|
||||
- **Таблицы миграций** нужны для синхронизации: приложение читает список миграций и отчитывается о применённых.
|
||||
- Если видите ошибку `Access denied for user ...@'<ip>'`, проверьте наличие конфликтующих записей пользователя с разными хостами (user@localhost vs user@'%').
|
||||
|
||||
### 4. Импорт метаданных компонентов
|
||||
|
||||
@@ -193,6 +214,7 @@ make build-all # Сборка для всех платформ (Linux, mac
|
||||
make build-windows # Только для Windows
|
||||
make run # Запуск dev сервера
|
||||
make test # Запуск тестов
|
||||
make install-hooks # Установить git hooks (блокировка коммита с секретами)
|
||||
make clean # Очистка bin/
|
||||
make help # Показать все команды
|
||||
```
|
||||
@@ -222,6 +244,44 @@ make help # Показать все команды
|
||||
- sync API возвращает `423 Locked` с `reason_code` и `reason_text`;
|
||||
- в UI показывается красный индикатор и причина блокировки в модалке синхронизации.
|
||||
|
||||
#### Схема потоков данных синхронизации
|
||||
|
||||
```text
|
||||
[ SERVER / MariaDB ]
|
||||
┌───────────────────────────┐
|
||||
│ qt_projects │
|
||||
│ qt_configurations │
|
||||
│ qt_pricelists │
|
||||
│ qt_pricelist_items │
|
||||
│ qt_pricelist_sync_status │
|
||||
└─────────────┬─────────────┘
|
||||
│
|
||||
pull (projects/configs/pricelists)
|
||||
│
|
||||
┌──────────────────┴──────────────────┐
|
||||
│ │
|
||||
[ CLIENT A / local SQLite ] [ CLIENT B / local SQLite ]
|
||||
┌───────────────────────────────┐ ┌───────────────────────────────┐
|
||||
│ local_projects │ │ local_projects │
|
||||
│ local_configurations │ │ local_configurations │
|
||||
│ local_pricelists │ │ local_pricelists │
|
||||
│ local_pricelist_items │ │ local_pricelist_items │
|
||||
│ pending_changes (proj/config) │ │ pending_changes (proj/config) │
|
||||
└───────────────┬───────────────┘ └───────────────┬───────────────┘
|
||||
│ │
|
||||
push (projects/configurations only) push (projects/configurations only)
|
||||
│ │
|
||||
└──────────────────┬────────────────────┘
|
||||
│
|
||||
[ SERVER / MariaDB ]
|
||||
```
|
||||
|
||||
По сущностям:
|
||||
- Конфигурации: `Client <-> Server <-> Other Clients`
|
||||
- Проекты: `Client <-> Server <-> Other Clients`
|
||||
- Прайслисты: `Server -> Clients only` (локальный push отсутствует)
|
||||
- Локальная очистка прайслистов на клиенте: удаляются записи, которых нет на сервере и которые не используются активными локальными конфигурациями
|
||||
|
||||
### Версионность конфигураций (local-first)
|
||||
|
||||
Для `local_configurations` используется append-only versioning через полные snapshot-версии:
|
||||
@@ -255,6 +315,7 @@ POST /api/configs/:uuid/rollback
|
||||
### Локальный config.yaml
|
||||
|
||||
По умолчанию `qfs` ищет `config.yaml` в той же user-state папке, где лежит `qfs.db` (а не рядом с бинарником).
|
||||
Если файла нет, он создаётся автоматически. Если формат устарел, он автоматически мигрируется в runtime-формат (`server` + `logging`).
|
||||
Можно переопределить путь через `-config` или `QFS_CONFIG_PATH`.
|
||||
|
||||
## Docker
|
||||
@@ -285,12 +346,23 @@ quoteforge/
|
||||
│ ├── templates/ # HTML шаблоны
|
||||
│ └── static/ # CSS, JS, изображения
|
||||
├── migrations/ # SQL миграции
|
||||
├── config.yaml # Конфигурация
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── config.example.yaml # Пример конфигурации
|
||||
├── releases/
|
||||
│ └── memory/ # Changelog между тегами (v1.2.1.md, v1.2.2.md, ...)
|
||||
└── go.mod
|
||||
```
|
||||
|
||||
## Releases & Changelog
|
||||
|
||||
Change log между версиями хранится в `releases/memory/` каталоге в файлах вида `v{major}.{minor}.{patch}.md`.
|
||||
|
||||
Каждый файл содержит:
|
||||
- Список коммитов между версиями
|
||||
- Описание изменений и их влияния
|
||||
- Breaking changes и заметки о миграции
|
||||
|
||||
**Перед работой над кодом проверьте последний файл в этой папке, чтобы понять текущее состояние проекта.**
|
||||
|
||||
## Роли пользователей
|
||||
|
||||
| Роль | Описание |
|
||||
@@ -325,6 +397,17 @@ POST /api/sync/components # Pull components (423, если bloc
|
||||
POST /api/sync/pricelists # Pull pricelists (423, если blocked)
|
||||
```
|
||||
|
||||
### Краткая карта sync API
|
||||
|
||||
| Endpoint | Назначение | Поток |
|
||||
|----------|------------|-------|
|
||||
| `POST /api/sync/push` | Отправить локальные pending-изменения | `SQLite -> MariaDB` |
|
||||
| `POST /api/sync/components` | Подтянуть справочник компонентов | `MariaDB -> SQLite` |
|
||||
| `POST /api/sync/pricelists` | Подтянуть прайслисты и позиции | `MariaDB -> SQLite` |
|
||||
| `POST /api/sync/all` | Полный цикл: push + pull + импорт проектов/конфигураций | `двунаправленно` |
|
||||
| `GET /api/sync/readiness` | Статус preflight/readiness | `read-only` |
|
||||
| `GET /api/sync/status` / `GET /api/sync/info` | Сводка статуса и данных синхронизации | `read-only` |
|
||||
|
||||
#### Sync payload для versioning
|
||||
|
||||
События в `pending_changes` для конфигураций содержат:
|
||||
@@ -336,50 +419,6 @@ POST /api/sync/pricelists # Pull pricelists (423, если bloc
|
||||
|
||||
Это позволяет push-слою отправлять на сервер актуальное состояние и готовит основу для будущего conflict resolution.
|
||||
|
||||
## Cron Jobs
|
||||
|
||||
QuoteForge now includes automated cron jobs for maintenance tasks. These can be run using the built-in cron functionality in the Docker container.
|
||||
|
||||
### Docker Compose Setup
|
||||
|
||||
The Docker setup includes a dedicated cron service that runs the following jobs:
|
||||
|
||||
- **Alerts check**: Every hour (0 * * * *)
|
||||
- **Price updates**: Daily at 2 AM (0 2 * * *)
|
||||
- **Usage counter reset**: Weekly on Sunday at 1 AM (0 1 * * 0)
|
||||
- **Popularity score updates**: Daily at 3 AM (0 3 * * *)
|
||||
|
||||
To enable cron jobs in Docker, run:
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Manual Cron Job Execution
|
||||
|
||||
You can also run cron jobs manually using the quoteforge-cron binary:
|
||||
|
||||
```bash
|
||||
# Check and generate alerts
|
||||
go run ./cmd/cron -job=alerts
|
||||
|
||||
# Recalculate all prices
|
||||
go run ./cmd/cron -job=update-prices
|
||||
|
||||
# Reset usage counters
|
||||
go run ./cmd/cron -job=reset-counters
|
||||
|
||||
# Update popularity scores
|
||||
go run ./cmd/cron -job=update-popularity
|
||||
```
|
||||
|
||||
### Cron Job Details
|
||||
|
||||
- **Alerts check**: Generates alerts for components with high demand and stale prices, trending components without prices, and components with no recent quotes
|
||||
- **Price updates**: Recalculates prices for all components using configured methods (median, weighted median, average)
|
||||
- **Usage counter reset**: Resets weekly and monthly usage counters for components
|
||||
- **Popularity score updates**: Recalculates popularity scores based on supplier quote activity
|
||||
|
||||
## Разработка
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
145
cmd/qfs/main.go
145
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"
|
||||
@@ -62,18 +64,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"})
|
||||
@@ -113,6 +115,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 +131,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)
|
||||
@@ -316,6 +326,96 @@ func setConfigDefaults(cfg *config.Config) {
|
||||
}
|
||||
}
|
||||
|
||||
func ensureDefaultConfigFile(configPath string) error {
|
||||
if strings.TrimSpace(configPath) == "" {
|
||||
return fmt.Errorf("config path is empty")
|
||||
}
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
const defaultConfigYAML = `server:
|
||||
host: "127.0.0.1"
|
||||
port: 8080
|
||||
mode: "release"
|
||||
read_timeout: 30s
|
||||
write_timeout: 30s
|
||||
|
||||
logging:
|
||||
level: "info"
|
||||
format: "json"
|
||||
output: "stdout"
|
||||
`
|
||||
if err := os.WriteFile(configPath, []byte(defaultConfigYAML), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("created default config file", "path", configPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
type runtimeServerConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
Mode string `yaml:"mode"`
|
||||
ReadTimeout time.Duration `yaml:"read_timeout"`
|
||||
WriteTimeout time.Duration `yaml:"write_timeout"`
|
||||
}
|
||||
|
||||
type runtimeLoggingConfig struct {
|
||||
Level string `yaml:"level"`
|
||||
Format string `yaml:"format"`
|
||||
Output string `yaml:"output"`
|
||||
}
|
||||
|
||||
type runtimeConfigFile struct {
|
||||
Server runtimeServerConfig `yaml:"server"`
|
||||
Logging runtimeLoggingConfig `yaml:"logging"`
|
||||
}
|
||||
|
||||
// migrateConfigFileToRuntimeShape rewrites config.yaml in a minimal runtime format.
|
||||
// Deprecated sections from legacy configs are intentionally dropped.
|
||||
func migrateConfigFileToRuntimeShape(configPath string, cfg *config.Config) error {
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("config is nil")
|
||||
}
|
||||
|
||||
runtimeCfg := runtimeConfigFile{
|
||||
Server: runtimeServerConfig{
|
||||
Host: cfg.Server.Host,
|
||||
Port: cfg.Server.Port,
|
||||
Mode: cfg.Server.Mode,
|
||||
ReadTimeout: cfg.Server.ReadTimeout,
|
||||
WriteTimeout: cfg.Server.WriteTimeout,
|
||||
},
|
||||
Logging: runtimeLoggingConfig{
|
||||
Level: cfg.Logging.Level,
|
||||
Format: cfg.Logging.Format,
|
||||
Output: cfg.Logging.Output,
|
||||
},
|
||||
}
|
||||
|
||||
rendered, err := yaml.Marshal(&runtimeCfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal runtime config: %w", err)
|
||||
}
|
||||
|
||||
current, err := os.ReadFile(configPath)
|
||||
if err == nil && bytes.Equal(bytes.TrimSpace(current), bytes.TrimSpace(rendered)) {
|
||||
return nil
|
||||
}
|
||||
if err := os.WriteFile(configPath, rendered, 0644); err != nil {
|
||||
return fmt.Errorf("write runtime config: %w", err)
|
||||
}
|
||||
slog.Info("migrated config.yaml to runtime format", "path", configPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// runSetupMode starts a minimal server that only serves the setup page
|
||||
func runSetupMode(local *localdb.LocalDB) {
|
||||
restartSig := make(chan struct{}, 1)
|
||||
@@ -595,7 +695,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, componentService, projectService)
|
||||
pricelistHandler := handlers.NewPricelistHandler(local)
|
||||
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval)
|
||||
if err != nil {
|
||||
@@ -1052,6 +1152,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
"current_version": currentVersion,
|
||||
})
|
||||
})
|
||||
|
||||
configs.GET("/:uuid/export", exportHandler.ExportConfigCSV)
|
||||
}
|
||||
|
||||
projects := api.Group("/projects")
|
||||
@@ -1064,7 +1166,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" {
|
||||
@@ -1216,6 +1319,32 @@ 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"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
simplified := make([]ProjectSimple, 0, len(allProjects))
|
||||
for _, p := range allProjects {
|
||||
simplified = append(simplified, ProjectSimple{
|
||||
UUID: p.UUID,
|
||||
Name: p.Name,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, simplified)
|
||||
})
|
||||
|
||||
projects.POST("", func(c *gin.Context) {
|
||||
var req services.CreateProjectRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
|
||||
15
crontab
15
crontab
@@ -1,15 +0,0 @@
|
||||
# Cron jobs for QuoteForge
|
||||
# Run alerts check every hour
|
||||
0 * * * * /app/quoteforge-cron -job=alerts
|
||||
|
||||
# Run price updates daily at 2 AM
|
||||
0 2 * * * /app/quoteforge-cron -job=update-prices
|
||||
|
||||
# Reset weekly counters every Sunday at 1 AM
|
||||
0 1 * * 0 /app/quoteforge-cron -job=reset-counters
|
||||
|
||||
# Update popularity scores daily at 3 AM
|
||||
0 3 * * * /app/quoteforge-cron -job=update-popularity
|
||||
|
||||
# Log rotation (optional)
|
||||
# 0 0 * * * /usr/bin/logrotate /etc/logrotate.conf
|
||||
297
csv_export.md
Normal file
297
csv_export.md
Normal file
@@ -0,0 +1,297 @@
|
||||
# CSV Export Pattern (Go + GORM)
|
||||
|
||||
## Архитектура (3-слойная)
|
||||
|
||||
### 1. Handler Layer (HTTP)
|
||||
**Задачи**: Обработка HTTP-запроса, установка заголовков, инициация экспорта
|
||||
|
||||
```go
|
||||
func (h *PricelistHandler) ExportCSV(c *gin.Context) {
|
||||
// 1. Валидация параметров
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
|
||||
// 2. Получение метаданных для формирования имени файла
|
||||
pl, err := h.service.GetByID(uint(id))
|
||||
|
||||
// 3. Установка HTTP-заголовков для скачивания
|
||||
filename := fmt.Sprintf("pricelist_%s.csv", pl.Version)
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
|
||||
// 4. UTF-8 BOM для Excel-совместимости
|
||||
c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})
|
||||
|
||||
// 5. Настройка CSV writer
|
||||
writer := csv.NewWriter(c.Writer)
|
||||
writer.Comma = ';' // Точка с запятой для Excel
|
||||
defer writer.Flush()
|
||||
|
||||
// 6. Динамические заголовки (зависят от типа данных)
|
||||
isWarehouse := strings.ToLower(pl.Source) == "warehouse"
|
||||
var header []string
|
||||
if isWarehouse {
|
||||
header = []string{"Артикул", "Категория", "Описание", "Доступно", "Partnumbers", "Цена, $", "Настройки"}
|
||||
} else {
|
||||
header = []string{"Артикул", "Категория", "Описание", "Цена, $", "Настройки"}
|
||||
}
|
||||
writer.Write(header)
|
||||
|
||||
// 7. Streaming в batches через callback
|
||||
err = h.service.StreamItemsForExport(uint(id), 500, func(items []models.PricelistItem) error {
|
||||
for _, item := range items {
|
||||
row := buildRow(item, isWarehouse)
|
||||
if err := writer.Write(row); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
writer.Flush() // Flush после каждого batch
|
||||
return nil
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Service Layer
|
||||
**Задачи**: Оркестрация, делегирование в репозиторий
|
||||
|
||||
```go
|
||||
func (s *Service) StreamItemsForExport(pricelistID uint, batchSize int, callback func(items []models.PricelistItem) error) error {
|
||||
if s.repo == nil {
|
||||
return fmt.Errorf("offline mode: cannot stream pricelist items")
|
||||
}
|
||||
return s.repo.StreamItemsForExport(pricelistID, batchSize, callback)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Repository Layer (Критичный)
|
||||
**Задачи**: Batch-загрузка из БД, оптимизация запросов, enrichment
|
||||
|
||||
```go
|
||||
func (r *PricelistRepository) StreamItemsForExport(pricelistID uint, batchSize int, callback func(items []models.PricelistItem) error) error {
|
||||
if batchSize <= 0 {
|
||||
batchSize = 500 // Default batch size
|
||||
}
|
||||
|
||||
// Проверка типа pricelist для conditional enrichment
|
||||
var pl models.Pricelist
|
||||
isWarehouse := false
|
||||
if err := r.db.Select("source").Where("id = ?", pricelistID).First(&pl).Error; err == nil {
|
||||
isWarehouse = pl.Source == string(models.PricelistSourceWarehouse)
|
||||
}
|
||||
|
||||
offset := 0
|
||||
for {
|
||||
var items []models.PricelistItem
|
||||
|
||||
// ⚡ КЛЮЧЕВОЙ МОМЕНТ: JOIN для избежания N+1 запросов
|
||||
err := r.db.Table("qt_pricelist_items AS pi").
|
||||
Select("pi.*, COALESCE(l.lot_description, '') AS lot_description, COALESCE(l.lot_category, '') AS category").
|
||||
Joins("LEFT JOIN lot AS l ON l.lot_name = pi.lot_name").
|
||||
Where("pi.pricelist_id = ?", pricelistID).
|
||||
Order("pi.lot_name").
|
||||
Offset(offset).
|
||||
Limit(batchSize).
|
||||
Scan(&items).Error
|
||||
|
||||
if err != nil || len(items) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// Conditional enrichment для warehouse данных
|
||||
if isWarehouse {
|
||||
r.enrichWarehouseItems(items) // Добавление qty, partnumbers
|
||||
}
|
||||
|
||||
// Вызов callback для обработки batch
|
||||
if err := callback(items); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(items) < batchSize {
|
||||
break // Последний batch
|
||||
}
|
||||
|
||||
offset += batchSize
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
## Ключевые паттерны
|
||||
|
||||
### 1. Streaming (не загружать все в память)
|
||||
```go
|
||||
// ❌ НЕ ТАК:
|
||||
var allItems []Item
|
||||
db.Find(&allItems) // Может упасть на миллионах записей
|
||||
|
||||
// ✅ ТАК:
|
||||
for offset := 0; ; offset += batchSize {
|
||||
var batch []Item
|
||||
db.Offset(offset).Limit(batchSize).Find(&batch)
|
||||
processBatch(batch)
|
||||
if len(batch) < batchSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Callback Pattern для гибкости
|
||||
```go
|
||||
// Service не знает о CSV - может использоваться для любого экспорта
|
||||
func StreamItems(callback func([]Item) error) error
|
||||
```
|
||||
|
||||
### 3. JOIN для избежания N+1
|
||||
```go
|
||||
// ❌ N+1 problem:
|
||||
items := getItems()
|
||||
for _, item := range items {
|
||||
description := getLotDescription(item.LotName) // N запросов
|
||||
}
|
||||
|
||||
// ✅ JOIN:
|
||||
db.Table("items AS i").
|
||||
Select("i.*, COALESCE(l.description, '') AS description").
|
||||
Joins("LEFT JOIN lots AS l ON l.name = i.lot_name")
|
||||
```
|
||||
|
||||
### 4. UTF-8 BOM для Excel
|
||||
```go
|
||||
// Excel на Windows требует BOM для корректного отображения UTF-8
|
||||
c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})
|
||||
```
|
||||
|
||||
### 5. Точка с запятой для Excel
|
||||
```go
|
||||
writer := csv.NewWriter(c.Writer)
|
||||
writer.Comma = ';' // Excel в русской локали использует ;
|
||||
```
|
||||
|
||||
### 6. Graceful Error Handling
|
||||
```go
|
||||
// После начала streaming нельзя вернуть JSON
|
||||
if err != nil {
|
||||
// Уже начали писать CSV, поэтому пишем текст
|
||||
c.String(http.StatusInternalServerError, "Export failed: %v", err)
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
## Conditional Enrichment Pattern
|
||||
|
||||
```go
|
||||
// Для warehouse прайслистов добавляем дополнительные поля
|
||||
func (r *PricelistRepository) enrichWarehouseItems(items []models.PricelistItem) error {
|
||||
// 1. Собрать уникальные lot_names
|
||||
lots := make([]string, 0, len(items))
|
||||
seen := make(map[string]struct{})
|
||||
for _, item := range items {
|
||||
if _, ok := seen[item.LotName]; !ok {
|
||||
lots = append(lots, item.LotName)
|
||||
seen[item.LotName] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Batch-загрузка метрик (qty, partnumbers)
|
||||
qtyByLot, partnumbersByLot, err := warehouse.LoadLotMetrics(r.db, lots, true)
|
||||
|
||||
// 3. Обогащение items
|
||||
for i := range items {
|
||||
if qty, ok := qtyByLot[items[i].LotName]; ok {
|
||||
items[i].AvailableQty = &qty
|
||||
}
|
||||
items[i].Partnumbers = partnumbersByLot[items[i].LotName]
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
## Virtual Fields Pattern
|
||||
|
||||
```go
|
||||
type PricelistItem struct {
|
||||
// Stored fields
|
||||
ID uint `gorm:"primaryKey"`
|
||||
LotName string `gorm:"size:255"`
|
||||
Price float64 `gorm:"type:decimal(12,2)"`
|
||||
|
||||
// Virtual fields (populated via JOIN or programmatically)
|
||||
LotDescription string `gorm:"-:migration" json:"lot_description,omitempty"`
|
||||
Category string `gorm:"-:migration" json:"category,omitempty"`
|
||||
AvailableQty *float64 `gorm:"-" json:"available_qty,omitempty"`
|
||||
Partnumbers []string `gorm:"-" json:"partnumbers,omitempty"`
|
||||
}
|
||||
```
|
||||
- `gorm:"-:migration"` - не создавать колонку в БД, но маппить при SELECT
|
||||
- `gorm:"-"` - полностью игнорировать при БД операциях
|
||||
|
||||
## Checklist для CSV Export
|
||||
|
||||
- [ ] HTTP заголовки: Content-Type, Content-Disposition
|
||||
- [ ] UTF-8 BOM для Excel (0xEF, 0xBB, 0xBF)
|
||||
- [ ] Разделитель (`;` для русской локали Excel)
|
||||
- [ ] Streaming с batch processing (не загружать всё в память)
|
||||
- [ ] JOIN для избежания N+1 запросов
|
||||
- [ ] Flush после каждого batch
|
||||
- [ ] Graceful error handling (нельзя JSON после начала streaming)
|
||||
- [ ] Динамические заголовки (если нужно)
|
||||
- [ ] Conditional enrichment (если данные зависят от типа)
|
||||
|
||||
## Когда использовать этот паттерн
|
||||
|
||||
✅ **Используй когда:**
|
||||
- Экспорт больших датасетов (>1000 записей)
|
||||
- Нужна Excel-совместимость
|
||||
- Связанные данные из нескольких таблиц
|
||||
- Conditional логика enrichment
|
||||
|
||||
❌ **Не нужен когда:**
|
||||
- Малые датасеты (<100 записей) - можно загрузить всё сразу
|
||||
- Экспорт JSON/XML - другие подходы
|
||||
- Нет связанных данных - можно упростить
|
||||
|
||||
## Пример роутинга (Gin)
|
||||
|
||||
```go
|
||||
// В файле роутера
|
||||
func SetupRoutes(router *gin.Engine, handler *PricelistHandler) {
|
||||
api := router.Group("/api")
|
||||
{
|
||||
pricelists := api.Group("/pricelists")
|
||||
{
|
||||
pricelists.GET("/:id/export", handler.ExportCSV)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Импорты
|
||||
|
||||
```go
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
```
|
||||
|
||||
## Performance Notes
|
||||
|
||||
1. **Batch Size**: 500-1000 оптимально для большинства случаев
|
||||
2. **JOIN vs N+1**: JOIN на порядки быстрее при >100 записях
|
||||
3. **Memory**: Streaming позволяет экспортировать миллионы записей с минимальной памятью
|
||||
4. **Indexes**: Убедись что есть индексы на JOIN колонках
|
||||
|
||||
## Источник
|
||||
|
||||
Реализовано в проекте PriceForge:
|
||||
- Handler: `internal/handlers/pricelist.go:245-346`
|
||||
- Service: `internal/services/pricelist/service.go:373-379`
|
||||
- Repository: `internal/repository/pricelist.go:475-533`
|
||||
- Models: `internal/models/pricelist.go`
|
||||
BIN
dist/qfs-darwin-amd64
vendored
Executable file
BIN
dist/qfs-darwin-amd64
vendored
Executable file
Binary file not shown.
BIN
dist/qfs-darwin-arm64
vendored
Executable file
BIN
dist/qfs-darwin-arm64
vendored
Executable file
Binary file not shown.
BIN
dist/qfs-linux-amd64
vendored
Executable file
BIN
dist/qfs-linux-amd64
vendored
Executable file
Binary file not shown.
BIN
dist/qfs-windows-amd64.exe
vendored
Executable file
BIN
dist/qfs-windows-amd64.exe
vendored
Executable file
Binary file not shown.
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -14,23 +14,28 @@ type ExportHandler struct {
|
||||
exportService *services.ExportService
|
||||
configService services.ConfigurationGetter
|
||||
componentService *services.ComponentService
|
||||
projectService *services.ProjectService
|
||||
}
|
||||
|
||||
func NewExportHandler(
|
||||
exportService *services.ExportService,
|
||||
configService services.ConfigurationGetter,
|
||||
componentService *services.ComponentService,
|
||||
projectService *services.ProjectService,
|
||||
) *ExportHandler {
|
||||
return &ExportHandler{
|
||||
exportService: exportService,
|
||||
configService: configService,
|
||||
componentService: componentService,
|
||||
projectService: projectService,
|
||||
}
|
||||
}
|
||||
|
||||
type ExportRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Items []struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
ProjectName string `json:"project_name"`
|
||||
ProjectUUID string `json:"project_uuid"`
|
||||
Items []struct {
|
||||
LotName string `json:"lot_name" binding:"required"`
|
||||
Quantity int `json:"quantity" binding:"required,min=1"`
|
||||
UnitPrice float64 `json:"unit_price"`
|
||||
@@ -47,15 +52,36 @@ 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.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 name if available
|
||||
projectName := req.ProjectName
|
||||
if projectName == "" && req.ProjectUUID != "" {
|
||||
// Try to load project name from database
|
||||
username := middleware.GetUsername(c)
|
||||
if project, err := h.projectService.GetByUUID(req.ProjectUUID, username); err == nil && project != nil {
|
||||
projectName = project.Name
|
||||
}
|
||||
}
|
||||
if projectName == "" {
|
||||
projectName = req.Name
|
||||
}
|
||||
|
||||
// Set headers before streaming
|
||||
exportDate := data.CreatedAt
|
||||
filename := fmt.Sprintf("%s (%s) %s BOM.csv", exportDate.Format("2006-01-02"), projectName, req.Name)
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
c.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 {
|
||||
@@ -101,6 +127,7 @@ 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()})
|
||||
@@ -109,13 +136,33 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
|
||||
|
||||
data := h.exportService.ConfigToExportData(config, h.componentService)
|
||||
|
||||
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.Items) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"})
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%s %s SPEC.csv", config.CreatedAt.Format("2006-01-02"), config.Name)
|
||||
// Get project name if configuration belongs to a project
|
||||
projectName := config.Name // fallback: use config name if no project
|
||||
if config.ProjectUUID != nil && *config.ProjectUUID != "" {
|
||||
if project, err := h.projectService.GetByUUID(*config.ProjectUUID, username); err == nil && project != nil {
|
||||
projectName = project.Name
|
||||
}
|
||||
}
|
||||
|
||||
// Set headers before streaming
|
||||
// Use price update time if available, otherwise creation time
|
||||
exportDate := config.CreatedAt
|
||||
if config.PriceUpdatedAt != nil {
|
||||
exportDate = *config.PriceUpdatedAt
|
||||
}
|
||||
filename := fmt.Sprintf("%s (%s) %s BOM.csv", exportDate.Format("2006-01-02"), projectName, config.Name)
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
314
internal/handlers/export_test.go
Normal file
314
internal/handlers/export_test.go
Normal file
@@ -0,0 +1,314 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Mock services for testing
|
||||
type mockConfigService struct {
|
||||
config *models.Configuration
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockConfigService) GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error) {
|
||||
return m.config, m.err
|
||||
}
|
||||
|
||||
|
||||
func TestExportCSV_Success(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// Create a basic mock component service that doesn't panic
|
||||
mockComponentService := &services.ComponentService{}
|
||||
|
||||
// Create handler with mocks
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
||||
handler := NewExportHandler(
|
||||
exportSvc,
|
||||
&mockConfigService{},
|
||||
mockComponentService,
|
||||
nil,
|
||||
)
|
||||
|
||||
// Create JSON request body
|
||||
jsonBody := `{
|
||||
"name": "Test Export",
|
||||
"items": [
|
||||
{
|
||||
"lot_name": "LOT-001",
|
||||
"quantity": 2,
|
||||
"unit_price": 100.50
|
||||
}
|
||||
],
|
||||
"notes": "Test notes"
|
||||
}`
|
||||
|
||||
// Create HTTP request
|
||||
req, _ := http.NewRequest("POST", "/api/export/csv", bytes.NewBufferString(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Create response recorder
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Create Gin context
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
|
||||
// Call handler
|
||||
handler.ExportCSV(c)
|
||||
|
||||
// Check status code
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Check Content-Type header
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if contentType != "text/csv; charset=utf-8" {
|
||||
t.Errorf("Expected Content-Type 'text/csv; charset=utf-8', got %q", contentType)
|
||||
}
|
||||
|
||||
// Check for BOM
|
||||
responseBody := w.Body.Bytes()
|
||||
if len(responseBody) < 3 {
|
||||
t.Fatalf("Response too short to contain BOM")
|
||||
}
|
||||
|
||||
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
||||
actualBOM := responseBody[:3]
|
||||
if bytes.Compare(actualBOM, expectedBOM) != 0 {
|
||||
t.Errorf("UTF-8 BOM mismatch. Expected %v, got %v", expectedBOM, actualBOM)
|
||||
}
|
||||
|
||||
// Check semicolon delimiter in CSV
|
||||
reader := csv.NewReader(bytes.NewReader(responseBody[3:]))
|
||||
reader.Comma = ';'
|
||||
|
||||
header, err := reader.Read()
|
||||
if err != nil {
|
||||
t.Errorf("Failed to parse CSV header: %v", err)
|
||||
}
|
||||
|
||||
if len(header) != 6 {
|
||||
t.Errorf("Expected 6 columns, got %d", len(header))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportCSV_InvalidRequest(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
||||
handler := NewExportHandler(
|
||||
exportSvc,
|
||||
&mockConfigService{},
|
||||
&services.ComponentService{},
|
||||
nil,
|
||||
)
|
||||
|
||||
// Create invalid request (missing required field)
|
||||
req, _ := http.NewRequest("POST", "/api/export/csv", bytes.NewBufferString(`{"name": "Test"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
|
||||
handler.ExportCSV(c)
|
||||
|
||||
// Should return 400 Bad Request
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Should return JSON error
|
||||
var errResp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &errResp)
|
||||
if _, hasError := errResp["error"]; !hasError {
|
||||
t.Errorf("Expected error in JSON response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportCSV_EmptyItems(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
||||
handler := NewExportHandler(
|
||||
exportSvc,
|
||||
&mockConfigService{},
|
||||
&services.ComponentService{},
|
||||
nil,
|
||||
)
|
||||
|
||||
// Create request with empty items array - should fail binding validation
|
||||
req, _ := http.NewRequest("POST", "/api/export/csv", bytes.NewBufferString(`{"name":"Empty Export","items":[],"notes":""}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
|
||||
handler.ExportCSV(c)
|
||||
|
||||
// Should return 400 Bad Request (validation error from gin binding)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Logf("Status code: %d (expected 400 for empty items)", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportConfigCSV_Success(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// Mock configuration
|
||||
mockConfig := &models.Configuration{
|
||||
UUID: "test-uuid",
|
||||
Name: "Test Config",
|
||||
OwnerUsername: "testuser",
|
||||
Items: models.ConfigItems{
|
||||
{
|
||||
LotName: "LOT-001",
|
||||
Quantity: 1,
|
||||
UnitPrice: 100.0,
|
||||
},
|
||||
},
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
||||
handler := NewExportHandler(
|
||||
exportSvc,
|
||||
&mockConfigService{config: mockConfig},
|
||||
&services.ComponentService{},
|
||||
nil,
|
||||
)
|
||||
|
||||
// Create HTTP request
|
||||
req, _ := http.NewRequest("GET", "/api/configs/test-uuid/export", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
c.Params = gin.Params{
|
||||
{Key: "uuid", Value: "test-uuid"},
|
||||
}
|
||||
|
||||
// Mock middleware.GetUsername
|
||||
c.Set("username", "testuser")
|
||||
|
||||
handler.ExportConfigCSV(c)
|
||||
|
||||
// Check status code
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Check Content-Type header
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if contentType != "text/csv; charset=utf-8" {
|
||||
t.Errorf("Expected Content-Type 'text/csv; charset=utf-8', got %q", contentType)
|
||||
}
|
||||
|
||||
// Check for BOM
|
||||
responseBody := w.Body.Bytes()
|
||||
if len(responseBody) < 3 {
|
||||
t.Fatalf("Response too short to contain BOM")
|
||||
}
|
||||
|
||||
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
||||
actualBOM := responseBody[:3]
|
||||
if bytes.Compare(actualBOM, expectedBOM) != 0 {
|
||||
t.Errorf("UTF-8 BOM mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportConfigCSV_NotFound(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
||||
handler := NewExportHandler(
|
||||
exportSvc,
|
||||
&mockConfigService{err: errors.New("config not found")},
|
||||
&services.ComponentService{},
|
||||
nil,
|
||||
)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/api/configs/nonexistent-uuid/export", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
c.Params = gin.Params{
|
||||
{Key: "uuid", Value: "nonexistent-uuid"},
|
||||
}
|
||||
c.Set("username", "testuser")
|
||||
|
||||
handler.ExportConfigCSV(c)
|
||||
|
||||
// Should return 404 Not Found
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("Expected status 404, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Should return JSON error
|
||||
var errResp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &errResp)
|
||||
if _, hasError := errResp["error"]; !hasError {
|
||||
t.Errorf("Expected error in JSON response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportConfigCSV_EmptyItems(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// Mock configuration with empty items
|
||||
mockConfig := &models.Configuration{
|
||||
UUID: "test-uuid",
|
||||
Name: "Empty Config",
|
||||
OwnerUsername: "testuser",
|
||||
Items: models.ConfigItems{},
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
||||
handler := NewExportHandler(
|
||||
exportSvc,
|
||||
&mockConfigService{config: mockConfig},
|
||||
&services.ComponentService{},
|
||||
nil,
|
||||
)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/api/configs/test-uuid/export", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
c.Params = gin.Params{
|
||||
{Key: "uuid", Value: "test-uuid"},
|
||||
}
|
||||
c.Set("username", "testuser")
|
||||
|
||||
handler.ExportConfigCSV(c)
|
||||
|
||||
// Should return 400 Bad Request
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Should return JSON error
|
||||
var errResp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &errResp)
|
||||
if _, hasError := errResp["error"]; !hasError {
|
||||
t.Errorf("Expected error in JSON response")
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -312,98 +303,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
|
||||
}
|
||||
|
||||
@@ -213,17 +213,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,
|
||||
|
||||
@@ -719,6 +719,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
|
||||
|
||||
@@ -58,6 +58,16 @@ 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,
|
||||
},
|
||||
}
|
||||
|
||||
func runLocalMigrations(db *gorm.DB) error {
|
||||
@@ -316,3 +326,113 @@ 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
|
||||
}
|
||||
|
||||
@@ -96,8 +96,10 @@ type LocalConfiguration struct {
|
||||
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"`
|
||||
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
|
||||
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
|
||||
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
|
||||
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
|
||||
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
@@ -179,14 +181,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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ package repository
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/lotmatch"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -243,9 +245,91 @@ func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, sear
|
||||
}
|
||||
}
|
||||
|
||||
if err := r.enrichItemsWithStock(items); err != nil {
|
||||
return nil, 0, fmt.Errorf("enriching pricelist items with stock: %w", err)
|
||||
}
|
||||
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||
@@ -40,14 +42,21 @@ type ExportItem struct {
|
||||
TotalPrice float64
|
||||
}
|
||||
|
||||
func (s *ExportService) ToCSV(data *ExportData) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
w := csv.NewWriter(&buf)
|
||||
func (s *ExportService) ToCSV(w io.Writer, data *ExportData) error {
|
||||
// Write UTF-8 BOM for Excel compatibility
|
||||
if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
|
||||
return fmt.Errorf("failed to write BOM: %w", err)
|
||||
}
|
||||
|
||||
csvWriter := csv.NewWriter(w)
|
||||
// Use semicolon as delimiter for Russian Excel locale
|
||||
csvWriter.Comma = ';'
|
||||
defer csvWriter.Flush()
|
||||
|
||||
// Header
|
||||
headers := []string{"Артикул", "Описание", "Категория", "Количество", "Цена за единицу", "Сумма"}
|
||||
if err := w.Write(headers); err != nil {
|
||||
return nil, err
|
||||
if err := csvWriter.Write(headers); err != nil {
|
||||
return fmt.Errorf("failed to write header: %w", err)
|
||||
}
|
||||
|
||||
// Get category hierarchy for sorting
|
||||
@@ -90,21 +99,35 @@ func (s *ExportService) ToCSV(data *ExportData) ([]byte, error) {
|
||||
item.Description,
|
||||
item.Category,
|
||||
fmt.Sprintf("%d", item.Quantity),
|
||||
fmt.Sprintf("%.2f", item.UnitPrice),
|
||||
fmt.Sprintf("%.2f", item.TotalPrice),
|
||||
strings.ReplaceAll(fmt.Sprintf("%.2f", item.UnitPrice), ".", ","),
|
||||
strings.ReplaceAll(fmt.Sprintf("%.2f", item.TotalPrice), ".", ","),
|
||||
}
|
||||
if err := w.Write(row); err != nil {
|
||||
return nil, err
|
||||
if err := csvWriter.Write(row); err != nil {
|
||||
return fmt.Errorf("failed to write row: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Total row
|
||||
if err := w.Write([]string{"", "", "", "", "ИТОГО:", fmt.Sprintf("%.2f", data.Total)}); err != nil {
|
||||
return nil, err
|
||||
totalStr := strings.ReplaceAll(fmt.Sprintf("%.2f", data.Total), ".", ",")
|
||||
if err := csvWriter.Write([]string{"", "", "", "", "ИТОГО:", totalStr}); err != nil {
|
||||
return fmt.Errorf("failed to write total row: %w", err)
|
||||
}
|
||||
|
||||
w.Flush()
|
||||
return buf.Bytes(), w.Error()
|
||||
csvWriter.Flush()
|
||||
if err := csvWriter.Error(); err != nil {
|
||||
return fmt.Errorf("csv writer error: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToCSVBytes is a backward-compatible wrapper that returns CSV data as bytes
|
||||
func (s *ExportService) ToCSVBytes(data *ExportData) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := s.ToCSV(&buf, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func (s *ExportService) ConfigToExportData(config *models.Configuration, componentService *ComponentService) *ExportData {
|
||||
|
||||
343
internal/services/export_test.go
Normal file
343
internal/services/export_test.go
Normal file
@@ -0,0 +1,343 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||
)
|
||||
|
||||
|
||||
func TestToCSV_UTF8BOM(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil)
|
||||
|
||||
data := &ExportData{
|
||||
Name: "Test",
|
||||
Items: []ExportItem{
|
||||
{
|
||||
LotName: "LOT-001",
|
||||
Description: "Test Item",
|
||||
Category: "CAT",
|
||||
Quantity: 1,
|
||||
UnitPrice: 100.0,
|
||||
TotalPrice: 100.0,
|
||||
},
|
||||
},
|
||||
Total: 100.0,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := svc.ToCSV(&buf, data); err != nil {
|
||||
t.Fatalf("ToCSV failed: %v", err)
|
||||
}
|
||||
|
||||
csvBytes := buf.Bytes()
|
||||
if len(csvBytes) < 3 {
|
||||
t.Fatalf("CSV too short to contain BOM")
|
||||
}
|
||||
|
||||
// Check UTF-8 BOM: 0xEF 0xBB 0xBF
|
||||
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
||||
actualBOM := csvBytes[:3]
|
||||
|
||||
if bytes.Compare(actualBOM, expectedBOM) != 0 {
|
||||
t.Errorf("UTF-8 BOM mismatch. Expected %v, got %v", expectedBOM, actualBOM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCSV_SemicolonDelimiter(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil)
|
||||
|
||||
data := &ExportData{
|
||||
Name: "Test",
|
||||
Items: []ExportItem{
|
||||
{
|
||||
LotName: "LOT-001",
|
||||
Description: "Test Item",
|
||||
Category: "CAT",
|
||||
Quantity: 2,
|
||||
UnitPrice: 100.50,
|
||||
TotalPrice: 201.00,
|
||||
},
|
||||
},
|
||||
Total: 201.00,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := svc.ToCSV(&buf, data); err != nil {
|
||||
t.Fatalf("ToCSV failed: %v", err)
|
||||
}
|
||||
|
||||
// Skip BOM and read CSV with semicolon delimiter
|
||||
csvBytes := buf.Bytes()
|
||||
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
||||
reader.Comma = ';'
|
||||
|
||||
// Read header
|
||||
header, err := reader.Read()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read header: %v", err)
|
||||
}
|
||||
|
||||
if len(header) != 6 {
|
||||
t.Errorf("Expected 6 columns, got %d", len(header))
|
||||
}
|
||||
|
||||
expectedHeader := []string{"Артикул", "Описание", "Категория", "Количество", "Цена за единицу", "Сумма"}
|
||||
for i, col := range expectedHeader {
|
||||
if i < len(header) && header[i] != col {
|
||||
t.Errorf("Column %d: expected %q, got %q", i, col, header[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Read item row
|
||||
itemRow, err := reader.Read()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read item row: %v", err)
|
||||
}
|
||||
|
||||
if itemRow[0] != "LOT-001" {
|
||||
t.Errorf("Lot name mismatch: expected LOT-001, got %s", itemRow[0])
|
||||
}
|
||||
|
||||
if itemRow[3] != "2" {
|
||||
t.Errorf("Quantity mismatch: expected 2, got %s", itemRow[3])
|
||||
}
|
||||
|
||||
if itemRow[4] != "100,50" {
|
||||
t.Errorf("Unit price mismatch: expected 100,50, got %s", itemRow[4])
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCSV_TotalRow(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil)
|
||||
|
||||
data := &ExportData{
|
||||
Name: "Test",
|
||||
Items: []ExportItem{
|
||||
{
|
||||
LotName: "LOT-001",
|
||||
Description: "Item 1",
|
||||
Category: "CAT",
|
||||
Quantity: 1,
|
||||
UnitPrice: 100.0,
|
||||
TotalPrice: 100.0,
|
||||
},
|
||||
{
|
||||
LotName: "LOT-002",
|
||||
Description: "Item 2",
|
||||
Category: "CAT",
|
||||
Quantity: 2,
|
||||
UnitPrice: 50.0,
|
||||
TotalPrice: 100.0,
|
||||
},
|
||||
},
|
||||
Total: 200.0,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := svc.ToCSV(&buf, data); err != nil {
|
||||
t.Fatalf("ToCSV failed: %v", err)
|
||||
}
|
||||
|
||||
csvBytes := buf.Bytes()
|
||||
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
||||
reader.Comma = ';'
|
||||
|
||||
// Skip header and item rows
|
||||
reader.Read()
|
||||
reader.Read()
|
||||
reader.Read()
|
||||
|
||||
// Read total row
|
||||
totalRow, err := reader.Read()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read total row: %v", err)
|
||||
}
|
||||
|
||||
// Total row should have "ИТОГО:" in position 4 and total value in position 5
|
||||
if totalRow[4] != "ИТОГО:" {
|
||||
t.Errorf("Expected 'ИТОГО:' in column 4, got %q", totalRow[4])
|
||||
}
|
||||
|
||||
if totalRow[5] != "200,00" {
|
||||
t.Errorf("Expected total 200,00, got %s", totalRow[5])
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCSV_CategorySorting(t *testing.T) {
|
||||
// Test category sorting without category repo (items maintain original order)
|
||||
svc := NewExportService(config.ExportConfig{}, nil)
|
||||
|
||||
data := &ExportData{
|
||||
Name: "Test",
|
||||
Items: []ExportItem{
|
||||
{
|
||||
LotName: "LOT-001",
|
||||
Category: "CAT-A",
|
||||
Quantity: 1,
|
||||
UnitPrice: 100.0,
|
||||
TotalPrice: 100.0,
|
||||
},
|
||||
{
|
||||
LotName: "LOT-002",
|
||||
Category: "CAT-C",
|
||||
Quantity: 1,
|
||||
UnitPrice: 100.0,
|
||||
TotalPrice: 100.0,
|
||||
},
|
||||
{
|
||||
LotName: "LOT-003",
|
||||
Category: "CAT-B",
|
||||
Quantity: 1,
|
||||
UnitPrice: 100.0,
|
||||
TotalPrice: 100.0,
|
||||
},
|
||||
},
|
||||
Total: 300.0,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := svc.ToCSV(&buf, data); err != nil {
|
||||
t.Fatalf("ToCSV failed: %v", err)
|
||||
}
|
||||
|
||||
csvBytes := buf.Bytes()
|
||||
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
||||
reader.Comma = ';'
|
||||
|
||||
// Skip header
|
||||
reader.Read()
|
||||
|
||||
// Without category repo, items maintain original order
|
||||
row1, _ := reader.Read()
|
||||
if row1[0] != "LOT-001" {
|
||||
t.Errorf("Expected LOT-001 first, got %s", row1[0])
|
||||
}
|
||||
|
||||
row2, _ := reader.Read()
|
||||
if row2[0] != "LOT-002" {
|
||||
t.Errorf("Expected LOT-002 second, got %s", row2[0])
|
||||
}
|
||||
|
||||
row3, _ := reader.Read()
|
||||
if row3[0] != "LOT-003" {
|
||||
t.Errorf("Expected LOT-003 third, got %s", row3[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCSV_EmptyData(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil)
|
||||
|
||||
data := &ExportData{
|
||||
Name: "Test",
|
||||
Items: []ExportItem{},
|
||||
Total: 0.0,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := svc.ToCSV(&buf, data); err != nil {
|
||||
t.Fatalf("ToCSV failed: %v", err)
|
||||
}
|
||||
|
||||
csvBytes := buf.Bytes()
|
||||
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
||||
reader.Comma = ';'
|
||||
|
||||
// Should have header and total row
|
||||
header, err := reader.Read()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read header: %v", err)
|
||||
}
|
||||
|
||||
if len(header) != 6 {
|
||||
t.Errorf("Expected 6 columns, got %d", len(header))
|
||||
}
|
||||
|
||||
totalRow, err := reader.Read()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read total row: %v", err)
|
||||
}
|
||||
|
||||
if totalRow[4] != "ИТОГО:" {
|
||||
t.Errorf("Expected ИТОГО: in total row, got %s", totalRow[4])
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCSVBytes_BackwardCompat(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil)
|
||||
|
||||
data := &ExportData{
|
||||
Name: "Test",
|
||||
Items: []ExportItem{
|
||||
{
|
||||
LotName: "LOT-001",
|
||||
Description: "Test Item",
|
||||
Category: "CAT",
|
||||
Quantity: 1,
|
||||
UnitPrice: 100.0,
|
||||
TotalPrice: 100.0,
|
||||
},
|
||||
},
|
||||
Total: 100.0,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
csvBytes, err := svc.ToCSVBytes(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ToCSVBytes failed: %v", err)
|
||||
}
|
||||
|
||||
if len(csvBytes) < 3 {
|
||||
t.Fatalf("CSV bytes too short")
|
||||
}
|
||||
|
||||
// Verify BOM is present
|
||||
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
||||
actualBOM := csvBytes[:3]
|
||||
if bytes.Compare(actualBOM, expectedBOM) != 0 {
|
||||
t.Errorf("UTF-8 BOM mismatch in ToCSVBytes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCSV_WriterError(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil)
|
||||
|
||||
data := &ExportData{
|
||||
Name: "Test",
|
||||
Items: []ExportItem{
|
||||
{
|
||||
LotName: "LOT-001",
|
||||
Description: "Test",
|
||||
Category: "CAT",
|
||||
Quantity: 1,
|
||||
UnitPrice: 100.0,
|
||||
TotalPrice: 100.0,
|
||||
},
|
||||
},
|
||||
Total: 100.0,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Use a failing writer
|
||||
failingWriter := &failingWriter{}
|
||||
|
||||
if err := svc.ToCSV(failingWriter, data); err == nil {
|
||||
t.Errorf("Expected error from failing writer, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// failingWriter always returns an error
|
||||
type failingWriter struct{}
|
||||
|
||||
func (fw *failingWriter) Write(p []byte) (int, error) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
@@ -347,7 +347,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 +362,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
|
||||
@@ -672,7 +660,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 +675,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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -340,19 +340,16 @@ 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
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -381,21 +378,14 @@ 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)
|
||||
}
|
||||
|
||||
// Update last sync time
|
||||
@@ -542,24 +532,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
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
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.
|
||||
@@ -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] Обновить описание приложения
|
||||
@@ -285,6 +285,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');
|
||||
|
||||
@@ -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,12 +54,12 @@
|
||||
|
||||
<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="Начните вводить название проекта"
|
||||
@@ -785,44 +782,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) {
|
||||
@@ -835,12 +807,17 @@ async function loadProjectsForConfigUI() {
|
||||
projectsCache = [];
|
||||
projectNameByUUID = {};
|
||||
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 => {
|
||||
// For compatibility with rest of code, populate projectsCache but mainly use projectNameByUUID
|
||||
projectsCache = allProjects;
|
||||
|
||||
allProjects.forEach(project => {
|
||||
projectNameByUUID[project.uuid] = project.name;
|
||||
});
|
||||
|
||||
|
||||
@@ -326,6 +326,8 @@ let ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
|
||||
// State
|
||||
let configUUID = '{{.ConfigUUID}}';
|
||||
let configName = '';
|
||||
let projectUUID = '';
|
||||
let projectName = '';
|
||||
let currentTab = 'base';
|
||||
let allComponents = [];
|
||||
let cart = [];
|
||||
@@ -351,6 +353,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;
|
||||
@@ -607,6 +611,7 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
|
||||
const config = await resp.json();
|
||||
configName = config.name;
|
||||
projectUUID = config.project_uuid || '';
|
||||
document.getElementById('config-name').textContent = config.name;
|
||||
document.getElementById('save-buttons').classList.remove('hidden');
|
||||
|
||||
@@ -1201,12 +1206,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 +1262,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 +1345,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 +1381,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 +1399,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 +1440,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 +1468,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 +1504,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 +1545,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,
|
||||
@@ -1716,6 +1773,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;
|
||||
|
||||
@@ -1733,14 +1798,14 @@ async function exportCSV() {
|
||||
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})
|
||||
});
|
||||
|
||||
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) {
|
||||
@@ -1986,14 +2051,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) {
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -385,40 +385,48 @@ async function copyProject(projectUUID, projectName) {
|
||||
loadProjects();
|
||||
}
|
||||
|
||||
loadProjects();
|
||||
|
||||
document.getElementById('projects-search').addEventListener('input', function(e) {
|
||||
projectsSearch = (e.target.value || '').trim();
|
||||
currentPage = 1;
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadProjects();
|
||||
});
|
||||
|
||||
document.getElementById('create-project-code').addEventListener('input', function() {
|
||||
updateCreateProjectTrackerURL();
|
||||
});
|
||||
document.getElementById('projects-search').addEventListener('input', function(e) {
|
||||
projectsSearch = (e.target.value || '').trim();
|
||||
currentPage = 1;
|
||||
loadProjects();
|
||||
});
|
||||
|
||||
document.getElementById('create-project-tracker-url').addEventListener('input', function(e) {
|
||||
createProjectTrackerManuallyEdited = (e.target.value || '').trim() !== createProjectLastAutoTrackerURL;
|
||||
});
|
||||
document.getElementById('create-project-code').addEventListener('input', function() {
|
||||
updateCreateProjectTrackerURL();
|
||||
});
|
||||
|
||||
document.getElementById('create-project-code').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
createProject();
|
||||
}
|
||||
});
|
||||
document.getElementById('create-project-tracker-url').addEventListener('input', function(e) {
|
||||
createProjectTrackerManuallyEdited = (e.target.value || '').trim() !== createProjectLastAutoTrackerURL;
|
||||
});
|
||||
|
||||
document.getElementById('create-project-tracker-url').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
createProject();
|
||||
}
|
||||
});
|
||||
document.getElementById('create-project-code').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
createProject();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('create-project-modal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeCreateProjectModal();
|
||||
}
|
||||
document.getElementById('create-project-tracker-url').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
createProject();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('create-project-modal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeCreateProjectModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for sync completion events from navbar
|
||||
window.addEventListener('sync-completed', function(e) {
|
||||
// Reset pagination and reload projects list
|
||||
loadProjects();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user